diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..7e715db --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,14 @@ +excluded: # paths to ignore during linting. Takes precedence over `included`. + - Pods + +disabled_rules: + - line_length + - identifier_name + - superfluous_disable_command + - todo + - cyclomatic_complexity + - function_body_length + - nesting + - file_length + - type_body_length + - trailing_whitespace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6a233e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +How to Contribute +----------------- +We'd love to accept your patches and contributions to this project. There are just a few small guidelines you need to follow. + +Getting Started +--------------- +We use [gitflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) so instead of a single `master` branch, we use two branches to record the history of the project. The `master` branch stores the official release history, and the `develop` branch serves as an integration branch for features. It's also convenient to tag all commits in the master branch with a version number. + +Workflow +-------- +If you would like to contribute to this project, please: +- Pick an [issue](https://github.com/SoftwareEngineeringDaily/se-daily-iOS/issues) to work on or create a [proposal](https://github.com/SoftwareEngineeringDaily/se-daily-iOS//blob/master/CONTRIBUTING.md#feature-proposals) for a new feature. +- Fork this project. +- Create your feature branch based off the `develop` branch. +- Create a pull request to get your worked reviewed and merged back into the upstream `develop` branch. + +Please include screenshots of your app changes and write down the test scenarios you followed to verify that your code works. + +Feature proposals +----------------- +Please create an issue, label it as a `feature proposal` and follow the following template: +```markdown +# Proposal: [your title here] + +## Summary +{Also include any designs or wireframes here} + +## Rationale +{First reason for why we should consider this proposal} +{Second reason for why we should consider this proposal} +{etc} + +## Scope + +## Important Notes + +## Open Questions +``` + +How to get unstuck +------------------ +We have an active Slack community that you can reach out to for more information or just to chat with anyone. Check out the [Slack Channel SED iOS app development](https://softwaredaily.slack.com/app_redirect?channel=sed_app_ios) slack channel. + +Also checkout the [Open Source Guide](https://softwareengineeringdaily.github.io/). + +Code reviews +------------ +All submissions, including submissions by project members, require review. We use GitHub pull requests for this purpose. Consult [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more information on using pull requests. + +Community Guidelines +-------------------- +- Be considerate, kind, constructive, and helpful. +- Do not engage in demeaning, discriminatory, harassing, or hateful speech, and imagery. diff --git a/Constants/L10nEnum.swift b/Constants/L10nEnum.swift index 2f8a005..d679cea 100644 --- a/Constants/L10nEnum.swift +++ b/Constants/L10nEnum.swift @@ -2,10 +2,13 @@ import Foundation +// swiftlint:disable superfluous_disable_command // swiftlint:disable file_length // swiftlint:disable explicit_type_interface identifier_name line_length nesting type_body_length type_name enum L10n { + /// Add a link + static let addLink = L10n.tr("Localizable", "AddLink") /// Email Field Empty static let alertMessageEmailEmpty = L10n.tr("Localizable", "AlertMessageEmailEmpty") /// Invalid Email Format @@ -32,18 +35,62 @@ enum L10n { static let alertMessagePasswordsDonotMatch = L10n.tr("Localizable", "AlertMessagePasswordsDonotMatch") /// Please Login static let alertMessagePleaseLogin = L10n.tr("Localizable", "AlertMessagePleaseLogin") + /// Username Field Empty + static let alertMessageUsernameEmpty = L10n.tr("Localizable", "AlertMessageUsernameEmpty") /// You must login to use this feature. static let alertMessageYouMustLogin = L10n.tr("Localizable", "AlertMessageYouMustLogin") + /// Anonymous + static let anonymous = L10n.tr("Localizable", "Anonymous") + /// Oh, sorry you're not liking the SEDaily app + static let appReviewApology = L10n.tr("Localizable", "AppReviewApology") + /// SEDaily iOS App Feedback + static let appReviewEmailSubject = L10n.tr("Localizable", "AppReviewEmailSubject") + /// Would you help us out by sending us your feedback? + static let appReviewGiveFeedbackQuestion = L10n.tr("Localizable", "AppReviewGiveFeedbackQuestion") + /// Enjoying the SEDaily app? + static let appReviewPromptQuestion = L10n.tr("Localizable", "AppReviewPromptQuestion") /// Cancel static let cancelButtonTitle = L10n.tr("Localizable", "CancelButtonTitle") + /// Cancel reply + static let cancelReplyButtonTitle = L10n.tr("Localizable", "CancelReplyButtonTitle") + /// Comments + static let comments = L10n.tr("Localizable", "Comments") + /// Add a comment... + static let commentsPlaceholder = L10n.tr("Localizable", "CommentsPlaceholder") /// Confirm Password static let confirmPasswordPlaceholder = L10n.tr("Localizable", "ConfirmPasswordPlaceholder") + /// Are you sure you want to delete this podcast? + static let deletePodcast = L10n.tr("Localizable", "DeletePodcast") + /// YEP! Delete it please. + static let deletePodcastButtonTitle = L10n.tr("Localizable", "DeletePodcastButtonTitle") + /// Edit Profile + static let editProfile = L10n.tr("Localizable", "EditProfile") /// Email static let emailAddressPlaceholder = L10n.tr("Localizable", "EmailAddressPlaceholder") + /// Please send an email to jeff@softwareengineeringdaily.com + static let emailUnsupportedMessage = L10n.tr("Localizable", "EmailUnsupportedMessage") + /// Email unsupported on this device + static let emailUnsupportedOnDevice = L10n.tr("Localizable", "EmailUnsupportedOnDevice") + /// No results for search + static let emptySearch = L10n.tr("Localizable", "EmptySearch") + /// Enable Notifications + static let enableNotifications = L10n.tr("Localizable", "EnableNotifications") + /// Hello! + static let enthusiasticHello = L10n.tr("Localizable", "EnthusiasticHello") + /// Sure! Send email + static let enthusiasticSureSendEmail = L10n.tr("Localizable", "EnthusiasticSureSendEmail") + /// Yes! + static let enthusiasticYes = L10n.tr("Localizable", "EnthusiasticYes") + /// Fetching results + static let fetchingSearch = L10n.tr("Localizable", "FetchingSearch") + /// Fields cannot be blank, please fill and retry + static let fieldEmpty = L10n.tr("Localizable", "FieldEmpty") /// First Name static let firstNamePlaceholder = L10n.tr("Localizable", "FirstNamePlaceholder") /// Error static let genericError = L10n.tr("Localizable", "GenericError") + /// No + static let genericNo = L10n.tr("Localizable", "GenericNo") /// OK static let genericOK = L10n.tr("Localizable", "GenericOK") /// Okay @@ -54,18 +101,64 @@ enum L10n { static let lastNamePlaceholder = L10n.tr("Localizable", "LastNamePlaceholder") /// Login static let loginButtonTitle = L10n.tr("Localizable", "LoginButtonTitle") + /// Login to see your saved episodes + static let loginSeeBookmarks = L10n.tr("Localizable", "LoginSeeBookmarks") /// Login static let loginTitle = L10n.tr("Localizable", "LoginTitle") /// Logout static let logoutTitle = L10n.tr("Localizable", "LogoutTitle") + /// We have new episodes for you! + static let mwfNotificationBody = L10n.tr("Localizable", "mwfNotificationBody") + /// Software Daily + static let mwfNotificationTitle = L10n.tr("Localizable", "mwfNotificationTitle") + /// Add new link + static let newLink = L10n.tr("Localizable", "NewLink") + /// No saved episodes. + static let noBookmarks = L10n.tr("Localizable", "NoBookmarks") + /// No downloaded episodes + static let noDownloads = L10n.tr("Localizable", "NoDownloads") + /// No thanks + static let noWithGratitude = L10n.tr("Localizable", "NoWithGratitude") /// Password static let passwordPlaceholder = L10n.tr("Localizable", "PasswordPlaceholder") /// Play static let play = L10n.tr("Localizable", "Play") + /// Post + static let post = L10n.tr("Localizable", "Post") + /// Related Links + static let relatedLinks = L10n.tr("Localizable", "RelatedLinks") + /// Reply + static let replyButtonTitle = L10n.tr("Localizable", "ReplyButtonTitle") + /// Search + static let search = L10n.tr("Localizable", "Search") + /// Add a short title + static let shortTitle = L10n.tr("Localizable", "ShortTitle") + /// Sign In + static let signInHeader = L10n.tr("Localizable", "SignInHeader") /// Sign Up static let signUpButtonTitle = L10n.tr("Localizable", "SignUpButtonTitle") + /// Sign Up + static let signUpHeader = L10n.tr("Localizable", "SignUpHeader") + /// Something went wrong, please try again + static let somethingWentWrong = L10n.tr("Localizable", "SomethingWentWrong") + /// Submit + static let submit = L10n.tr("Localizable", "Submit") + /// Submitting... + static let submitting = L10n.tr("Localizable", "Submitting") + /// Successfully submitted :) + static let succcessfullySubmitted = L10n.tr("Localizable", "SucccessfullySubmitted") + /// Downloads + static let tabBarDownloads = L10n.tr("Localizable", "TabBarDownloads") + /// Feed + static let tabBarFeed = L10n.tr("Localizable", "TabBarFeed") + /// Forum + static let tabBarForum = L10n.tr("Localizable", "TabBarForum") /// Just For You static let tabBarJustForYou = L10n.tr("Localizable", "TabBarJustForYou") + /// Notifications + static let tabBarNotifications = L10n.tr("Localizable", "TabBarNotifications") + /// Saved + static let tabBarSaved = L10n.tr("Localizable", "TabBarSaved") /// Latest static let tabBarTitleLatest = L10n.tr("Localizable", "TabBarTitleLatest") /// All @@ -90,6 +183,24 @@ enum L10n { static let tabTitleOpenSource = L10n.tr("Localizable", "TabTitleOpenSource") /// Security static let tabTitleSecurity = L10n.tr("Localizable", "TabTitleSecurity") + /// Tap to refresh + static let tapToRefresh = L10n.tr("Localizable", "TapToRefresh") + /// There was a problem :( + static let thereWasAProblem = L10n.tr("Localizable", "ThereWasAProblem") + /// minutes left + static let timeLeft = L10n.tr("Localizable", "TimeLeft") + /// back to sign in + static let toggleToSignInButtonTitle = L10n.tr("Localizable", "ToggleToSignInButtonTitle") + /// no account? sign up + static let toggleToSignUpButtonTitle = L10n.tr("Localizable", "ToggleToSignUpButtonTitle") + /// Transcript + static let transcript = L10n.tr("Localizable", "Transcript") + /// Type here + static let typeHere = L10n.tr("Localizable", "TypeHere") + /// Username or Email + static let usernameOrEmailPlaceholder = L10n.tr("Localizable", "UsernameOrEmailPlaceholder") + /// Username + static let usernamePlaceholder = L10n.tr("Localizable", "UsernamePlaceholder") } // swiftlint:enable explicit_type_interface identifier_name line_length nesting type_body_length type_name diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d5f9aba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016-2017 Kunal Kapadia + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Podfile b/Podfile index 04c07cb..2178633 100644 --- a/Podfile +++ b/Podfile @@ -7,34 +7,40 @@ target 'SEDaily-IOS' do # Pods for SEDaily-IOS pod 'ActiveLabel', :git => 'https://github.com/optonaut/ActiveLabel.swift.git' +p pod 'Alamofire', '~> 4.5.1' pod 'Crashlytics', '~> 3.9.3' + pod 'Down' pod 'Disk', '~> 0.3.1' - pod 'Eureka', '~> 4.0.1' - pod 'Fabric', '~> 1.7.2' - pod 'IQKeyboardManagerSwift', '~> 5.0.5' + pod 'Eureka', '~> 4.1.1' + pod 'Fabric', '~> 1.7.5' + pod 'Firebase/Core' + pod 'IQKeyboardManagerSwift', '~> 5.0.8' + pod 'Kingfisher', '~> 4.2.0' pod 'KTResponsiveUI', '~> 0.2.4' - pod 'KoalaTeaFlowLayout', '~> 0.3.1' - pod 'KoalaTeaPlayer', '~> 0.1.7' + pod 'MBProgressHUD', '~> 1.1.0' pod 'Pageboy', '~> 2.0.2' - pod 'PureLayout', '~> 3.0.2' + pod 'PopupDialog', '~> 0.6.0' pod 'Reusable', '~> 4.0.0' - pod 'SDWebImage', '~> 4.2.1' - pod 'SideMenu', '~> 3.1.4' pod 'Skeleton', '~> 0.1.0' pod 'SnapKit', '~> 4.0.0' + pod 'StatefulViewController', '~> 3.0' pod 'SwiftGen', '~> 5.2.1' - pod 'SwiftIcons', :git => 'https://github.com/themisterholliday/SwiftIcons.git', :branch => 'swift-4' + pod 'SwiftMoment' + pod 'SwiftLint', '~> 0.25.1' pod 'SwiftSoup', '~> 1.5.8' pod 'SwifterSwift', '~> 4.0.1' pod 'SwiftyBeaver', '~> 1.4.2' - pod 'SwiftyJSON', '~> 3.1.4' + pod 'SwiftyJSON' pod 'Tabman', '~> 1.0.5' - pod 'UIFontComplete', '~> 2.0.1' - + pod 'WaitForIt', '~> 2.0.0' + pod 'Tags' + target 'SEDaily-IOSTests' do inherit! :search_paths - # Pods for testing + pod 'Quick' + pod 'Nimble' + pod 'Mockingjay' end end diff --git a/Podfile.lock b/Podfile.lock index 4d3e31a..91ef989 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,29 +1,56 @@ PODS: - - ActiveLabel (0.7.1) + - ActiveLabel (0.8.1) - Alamofire (4.5.1) - Crashlytics (3.9.3): - Fabric (~> 1.7.2) - - Disk (0.3.1) - - Eureka (4.0.1) - - Fabric (1.7.2) - - IQKeyboardManagerSwift (5.0.5) - - KoalaTeaFlowLayout (0.3.1) - - KoalaTeaPlayer (0.1.7) - - KTResponsiveUI (0.2.4): - - SwiftIcons - - Pageboy (2.0.2) - - PureLayout (3.0.2) - - Reusable (4.0.0): - - Reusable/Storyboard (= 4.0.0) - - Reusable/View (= 4.0.0) - - Reusable/Storyboard (4.0.0) - - Reusable/View (4.0.0) - - SDWebImage (4.2.1): - - SDWebImage/Core (= 4.2.1) - - SDWebImage/Core (4.2.1) - - SideMenu (3.1.4) - - Skeleton (0.1.0) + - Disk (0.3.3) + - Down (0.5.1) + - Eureka (4.1.1) + - Fabric (1.7.5) + - Firebase/Core (4.13.0): + - FirebaseAnalytics (= 4.2.0) + - FirebaseCore (= 4.0.20) + - FirebaseAnalytics (4.2.0): + - FirebaseCore (~> 4.0) + - FirebaseInstanceID (~> 2.0) + - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" + - nanopb (~> 0.3) + - FirebaseCore (4.0.20): + - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" + - FirebaseInstanceID (2.0.10): + - FirebaseCore (~> 4.0) + - GoogleToolboxForMac/Defines (2.1.4) + - "GoogleToolboxForMac/NSData+zlib (2.1.4)": + - GoogleToolboxForMac/Defines (= 2.1.4) + - IQKeyboardManagerSwift (5.0.8) + - Kingfisher (4.2.0) + - KTResponsiveUI (0.2.6) + - MBProgressHUD (1.1.0) + - Mockingjay (2.0.1): + - Mockingjay/Core (= 2.0.1) + - Mockingjay/XCTest (= 2.0.1) + - Mockingjay/Core (2.0.1): + - URITemplate (~> 2.0) + - Mockingjay/XCTest (2.0.1): + - Mockingjay/Core + - nanopb (0.3.8): + - nanopb/decode (= 0.3.8) + - nanopb/encode (= 0.3.8) + - nanopb/decode (0.3.8) + - nanopb/encode (0.3.8) + - Nimble (7.0.3) + - Pageboy (2.0.4) + - PopupDialog (0.6.2) + - PureLayout (3.1.4) + - Quick (1.2.0) + - Reusable (4.0.2): + - Reusable/Storyboard (= 4.0.2) + - Reusable/View (= 4.0.2) + - Reusable/Storyboard (4.0.2) + - Reusable/View (4.0.2) + - Skeleton (0.1.2) - SnapKit (4.0.0) + - StatefulViewController (3.0) - SwifterSwift (4.0.1): - SwifterSwift/AppKit (= 4.0.1) - SwifterSwift/CoreGraphics (= 4.0.1) @@ -38,84 +65,140 @@ PODS: - SwifterSwift/SwiftStdlib (4.0.1) - SwifterSwift/UIKit (4.0.1) - SwiftGen (5.2.1) - - SwiftIcons (1.5.1) + - SwiftLint (0.25.1) + - SwiftMoment (0.7) - SwiftSoup (1.5.8) - - SwiftyBeaver (1.4.2) - - SwiftyJSON (3.1.4) - - Tabman (1.0.5): - - Pageboy (~> 2.0.0) - - PureLayout (~> 3.0.0) - - UIFontComplete (2.0.1) + - SwiftyBeaver (1.4.4) + - SwiftyJSON (5.0.0) + - Tabman (1.0.8): + - Pageboy (~> 2.0) + - PureLayout (~> 3.0) + - Tags (0.2.4) + - URITemplate (2.0.3) + - WaitForIt (2.0.0) DEPENDENCIES: - ActiveLabel (from `https://github.com/optonaut/ActiveLabel.swift.git`) - Alamofire (~> 4.5.1) - Crashlytics (~> 3.9.3) - Disk (~> 0.3.1) - - Eureka (~> 4.0.1) - - Fabric (~> 1.7.2) - - IQKeyboardManagerSwift (~> 5.0.5) - - KoalaTeaFlowLayout (~> 0.3.1) - - KoalaTeaPlayer (~> 0.1.7) + - Down + - Eureka (~> 4.1.1) + - Fabric (~> 1.7.5) + - Firebase/Core + - IQKeyboardManagerSwift (~> 5.0.8) + - Kingfisher (~> 4.2.0) - KTResponsiveUI (~> 0.2.4) + - MBProgressHUD (~> 1.1.0) + - Mockingjay + - Nimble - Pageboy (~> 2.0.2) - - PureLayout (~> 3.0.2) + - PopupDialog (~> 0.6.0) + - Quick - Reusable (~> 4.0.0) - - SDWebImage (~> 4.2.1) - - SideMenu (~> 3.1.4) - Skeleton (~> 0.1.0) - SnapKit (~> 4.0.0) + - StatefulViewController (~> 3.0) - SwifterSwift (~> 4.0.1) - SwiftGen (~> 5.2.1) - - SwiftIcons (from `https://github.com/themisterholliday/SwiftIcons.git`, branch `swift-4`) + - SwiftLint (~> 0.25.1) + - SwiftMoment - SwiftSoup (~> 1.5.8) - SwiftyBeaver (~> 1.4.2) - - SwiftyJSON (~> 3.1.4) + - SwiftyJSON - Tabman (~> 1.0.5) - - UIFontComplete (~> 2.0.1) + - Tags + - WaitForIt (~> 2.0.0) + +SPEC REPOS: + https://github.com/cocoapods/specs.git: + - Alamofire + - Crashlytics + - Disk + - Down + - Eureka + - Fabric + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseInstanceID + - GoogleToolboxForMac + - IQKeyboardManagerSwift + - Kingfisher + - KTResponsiveUI + - MBProgressHUD + - Mockingjay + - nanopb + - Nimble + - Pageboy + - PopupDialog + - PureLayout + - Quick + - Reusable + - Skeleton + - SnapKit + - StatefulViewController + - SwifterSwift + - SwiftGen + - SwiftLint + - SwiftMoment + - SwiftSoup + - SwiftyBeaver + - SwiftyJSON + - Tabman + - Tags + - URITemplate + - WaitForIt EXTERNAL SOURCES: ActiveLabel: :git: https://github.com/optonaut/ActiveLabel.swift.git - SwiftIcons: - :branch: swift-4 - :git: https://github.com/themisterholliday/SwiftIcons.git CHECKOUT OPTIONS: ActiveLabel: - :commit: 3f869d1245e3663379d92a2b918f0af7aec9cf89 + :commit: 3e98a76353bcdeea879e7d2552f58d6967207dab :git: https://github.com/optonaut/ActiveLabel.swift.git - SwiftIcons: - :commit: 56e9cf1019b01a4a9a2f22a78e84618f4ea094aa - :git: https://github.com/themisterholliday/SwiftIcons.git SPEC CHECKSUMS: - ActiveLabel: faa96b5f50507770536a3e48a4cf291ee88fb7db + ActiveLabel: 2fc3cdffed0dcdaad82d50af46233b9ef2f401e4 Alamofire: 2d95912bf4c34f164fdfc335872e8c312acaea4a Crashlytics: dbb07d01876c171c5ccbdf7826410380189e452c - Disk: 64f0f95a4163314aba6f09f010f17de964762140 - Eureka: c8bd5cc07143b6f66268c208d28a246c99b41955 - Fabric: 9cd6a848efcf1b8b07497e0b6a2e7d336353ba15 - IQKeyboardManagerSwift: ee393093cfb41069a72584a8f599ba5519a7febe - KoalaTeaFlowLayout: 219c45709d0873d405b2de1b27080a9c411246b7 - KoalaTeaPlayer: 52b9f24cf3730099164e06767e5d480b0532064e - KTResponsiveUI: 34a7f474fe9fcb8d99597b162b4ad5ca5d56342b - Pageboy: d5b9fe9a59202674c44111e513e67ccc680e18b7 - PureLayout: 4d550abe49a94f24c2808b9b95db9131685fe4cd - Reusable: 98e5fff1e0e2e00872199699b276dde08ee56c07 - SDWebImage: 1fa4c9edf525b744f002db89f3f5a38b3cd4c541 - SideMenu: 99021e70b3f2eee65b5dd88e4274d09031b1221e - Skeleton: b76249b7d6a73a9913e164c8030647a3e005c700 + Disk: d1f55cd61f6ca20f368232d0c6e37e3c3dfcb63e + Down: 6ace44ecec0c408826342ed420002f39a18e38c3 + Eureka: b88fb930e42c79f8c03c373d0fcdc28c1d6c50ed + Fabric: ae7146a5f505ea370a1e44820b4b1dc8890e2890 + Firebase: 5ec5e863d269d82d66b4bf56856726f8fb8f0fb3 + FirebaseAnalytics: 7ef69e76a5142f643aeb47c780e1cdce4e23632e + FirebaseCore: 90cb1c53d69b556f112a1bf72b5fcfaad7650790 + FirebaseInstanceID: 8d20d890d65c917f9f7d9950b6e10a760ad34321 + GoogleToolboxForMac: 91c824d21e85b31c2aae9bb011c5027c9b4e738f + IQKeyboardManagerSwift: 2e7dc7f98c111458c1ea2b373f893e8cf95e2b97 + Kingfisher: 9ee7e788d8ba07c3f21ce0d43f33cec310a4f781 + KTResponsiveUI: c8a75fe5270e05749a5ca8d20ab008a285b5e260 + MBProgressHUD: e7baa36a220447d8aeb12769bf0585582f3866d9 + Mockingjay: 11a621880d2887f1775bdcf824341eb68f218450 + nanopb: 5601e6bca2dbf1ed831b519092ec110f66982ca3 + Nimble: 7f5a9c447a33002645a071bddafbfb24ea70e0ac + Pageboy: 5970f6138b035380fd633b1813b7cc056784b784 + PopupDialog: d4336d7274a6aa3af4cb26d8b6691510eba90cc3 + PureLayout: f08c01b8dec00bb14a1fefa3de4c7d9c265df85e + Quick: 58d203b1c5e27fff7229c4c1ae445ad7069a7a08 + Reusable: 584a5629747c52e0f6a77841b400dc2643f6fa0f + Skeleton: 0d2941ce8d24f60a7e85ee39e55203256300b538 SnapKit: a42d492c16e80209130a3379f73596c3454b7694 + StatefulViewController: 4803bf900d44de26074344998e10e041113b5931 SwifterSwift: 5f406a5f831343312d6d5b34282b57ef3f52c40b SwiftGen: 444b4c467e512a4812b8a800d731643fd265139e - SwiftIcons: b20641adce6b71fcb2e72389b4b5f2e87252df99 + SwiftLint: ce933681be10c3266e82576dad676fa815a602e9 + SwiftMoment: dc6dd27012e267819104cbb03f74ff9192dd0b42 SwiftSoup: 86a1f5ad07b7e3d14ab1a9324a58c75ae2215e79 - SwiftyBeaver: 91057725648ee4980308f1650af077d04b3654a0 - SwiftyJSON: c2842d878f95482ffceec5709abc3d05680c0220 - Tabman: 9f87244fdbbd070fe6a518f445880b2b4a56ea1b - UIFontComplete: 7e3ce7f0a12d2529fb07f537e262aabfa87df280 + SwiftyBeaver: 25bd76281f49ca989ec2e3cbde9af89c15bc1432 + SwiftyJSON: 36413e04c44ee145039d332b4f4e2d3e8d6c4db7 + Tabman: 9f9a25595516c7dffb747be271ec8a0a5ac2341e + Tags: f513b25d753de97503f1bef5b4ff1fca4a0d5b29 + URITemplate: ace0c4c46dcf8afe6e89b4060621852886b15c3b + WaitForIt: 2c7ad6deaffe3bf727b7242a2263167bf2ce3e57 -PODFILE CHECKSUM: eda67d2e802f0442870d2d12c35e79ee9fe94cc0 +PODFILE CHECKSUM: 7cee19db3c2ecba4fc4f5ee08270355d84249fc2 -COCOAPODS: 1.3.1 +COCOAPODS: 1.7.3 diff --git a/README.md b/README.md index 65c5f77..36c6f8c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@ # Software Engineering Daily iOS App +[![logo](https://i.imgur.com/3OtP3p8.png)](https://softwareengineeringdaily.com/) + Native iOS app for [Software Engineering Daily](https://softwareengineeringdaily.com/). +## Screenshots + +![App screenshots](screenshots/app_screenshots.png) + +## Getting Started + +The app is 100% Swift. User interface is built mostly in code using [SnapKit](https://github.com/SnapKit/SnapKit) DSL. The current architecture is MVVM-C, but moving forward we are open to consider some Redux-like state management implementation. + ## Setup We are using [CocoaPods](http://cocoapods.org) to manage dependencies. - Clone the repo and then run: ``` @@ -18,3 +27,24 @@ CocoaPods requires that you open the *SEDaily-IOS.xcworkspace*. ``` $ open SEDaily-IOS.xcworkspace ``` + +## Dependencies + +There are +25 various dependencies used in the project. Moving forward some of them may be subject to change/removal. +The most important ones are: + +* [SnapKit](https://github.com/SnapKit/SnapKit) - Build layouts in code. +* [Alamofire](https://github.com/Alamofire/Alamofire) - Industry standard for networking. +* [Kingfisher](https://github.com/onevcat/Kingfisher) - Caching images. +* [SwiftLint](https://github.com/realm/SwiftLint) - Swift language linter. + + + +## Upcoming features + +Interested in seeing a particular feature implemented in this app? Please open a new [issue](https://github.com/SoftwareEngineeringDaily/se-daily-iOS/issues) with a [feature proposal](https://github.com/SoftwareEngineeringDaily/se-daily-iOS/blob/master/CONTRIBUTING.md#feature-proposals). + +Contributing +------------ +Checkout [CONTRIBUTING.md](https://github.com/SoftwareEngineeringDaily/se-daily-iOS/CONTRIBUTING.md) for details. + diff --git a/SEDaily-IOS.xcodeproj/project.pbxproj b/SEDaily-IOS.xcodeproj/project.pbxproj index 0920655..8db1aa3 100644 --- a/SEDaily-IOS.xcodeproj/project.pbxproj +++ b/SEDaily-IOS.xcodeproj/project.pbxproj @@ -3,18 +3,16 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 48; objects = { /* Begin PBXBuildFile section */ 01BB1D6D1F29999E004A912E /* PodcastPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */; }; - 161F3DE61F61F73D00A8F825 /* SearchTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */; }; - 161F3DEA1F62703100A8F825 /* PodcastTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */; }; + 161791FC1FC4DA7200A1287E /* OfflineDownloadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */; }; + 161F3DE61F61F73D00A8F825 /* SearchCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161F3DE51F61F73D00A8F825 /* SearchCollectionViewController.swift */; }; + 162FFD401FBE200E0026288D /* DiskKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FFD3F1FBE200E0026288D /* DiskKeys.swift */; }; 16307FCA1F8FDD02001783CB /* Podcast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16307FC91F8FDD02001783CB /* Podcast.swift */; }; 16307FCC1F8FEE3A001783CB /* PodcastViewModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16307FCB1F8FEE3A001783CB /* PodcastViewModelController.swift */; }; - 1649A78A1F204EC6005C4A6E /* ContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */; }; - 164C71051F021AC8003803BC /* CustomTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164C71041F021AC8003803BC /* CustomTabViewController.swift */; }; - 164FE9DC1F02DAB5009419CA /* PodcastCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */; }; 164FE9E11F02EE67009419CA /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E01F02EE67009419CA /* LoginViewController.swift */; }; 164FE9E51F02EE83009419CA /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E41F02EE83009419CA /* API.swift */; }; 164FE9E71F02F02F009419CA /* UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E61F02F02F009419CA /* UserModel.swift */; }; @@ -24,26 +22,120 @@ 164FE9EF1F03065C009419CA /* NavigationControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */; }; 165484551F902D3F005AEA23 /* GeneralCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */; }; 166036211F266FF300A22B7B /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 166036201F266FF300A22B7B /* Notifications.swift */; }; - 167AFAB71F043F1100A1332F /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 167AFAB61F043F1100A1332F /* HeaderView.swift */; }; + 1671BD56200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */; }; + 1671BD58200D361900E6ED3B /* SubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */; }; + 1671BD5A200D3D7800E6ED3B /* SubscriptionStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */; }; 1686FC011F009EC00088A6C1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1686FC001F009EC00088A6C1 /* AppDelegate.swift */; }; 1686FC061F009EC00088A6C1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC041F009EC00088A6C1 /* Main.storyboard */; }; 1686FC081F009EC00088A6C1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC071F009EC00088A6C1 /* Assets.xcassets */; }; 1686FC0B1F009EC00088A6C1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC091F009EC00088A6C1 /* LaunchScreen.storyboard */; }; - 1686FC161F009EC00088A6C1 /* SEDaily_IOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1686FC151F009EC00088A6C1 /* SEDaily_IOSTests.swift */; }; + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */; }; + 1688CB4C2006BDDA00440095 /* APIStripeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */; }; 169806A61F8EF3970075D8AD /* L10nEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 169806A51F8EF08F0075D8AD /* L10nEnum.swift */; }; 16AD3E0A1F9B138D0084C545 /* PodcastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E091F9B138D0084C545 /* PodcastViewModel.swift */; }; 16AD3E0C1F9B13EE0084C545 /* FilterObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */; }; 16AD3E0E1F9B14130084C545 /* PodcastDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */; }; - 16B147B11F16BF9C00433A42 /* AudioViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B147B01F16BF9C00433A42 /* AudioViewManager.swift */; }; - 16CE698F1F98029E0057BAC3 /* PodcastDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */; }; - 16CE69911F9807CF0057BAC3 /* PodcastDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CE69901F9807CF0057BAC3 /* PodcastDescriptionView.swift */; }; 16D67C4A1F33AC620065E838 /* AnswersTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D67C491F33AC620065E838 /* AnswersTracker.swift */; }; - 16D766BA1F06B4850066C143 /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D766B91F06B4850066C143 /* AudioView.swift */; }; 16F3A1BA1F90918D00364709 /* PodcastRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F3A1B91F90918D00364709 /* PodcastRepository.swift */; }; 16FA84031F8D323700A45D9B /* SkeletonCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */; }; + 1E286DE31FE4C5BA00644C1D /* TestHookEventTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */; }; + 1E286DE51FE4C63200644C1D /* TestHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE41FE4C63200644C1D /* TestHookManager.swift */; }; + 1E286DE91FE4C65200644C1D /* TestHookEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */; }; + 1E2BFDCD2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */; }; 1E44AF031F87B08D00221B22 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1E44AF051F87B08D00221B22 /* Localizable.strings */; }; + 1E638E561FC794AC00A29BDC /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */; }; + 1E706BD21FD620B100D44AB2 /* BookmarkCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */; }; + 1E706BD41FD62E0300D44AB2 /* BookmarkViewModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */; }; + 1EABD8F41FB430AB00959859 /* Debug.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1EABD8F11FB430AA00959859 /* Debug.storyboard */; }; + 1EABD8F51FB430AB00959859 /* DebugTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */; }; + 1EABD8F61FB430AB00959859 /* TestHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EABD8F31FB430AA00959859 /* TestHook.swift */; }; + 1EB74FFC1FE5E1AF004B733E /* StateBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */; }; 238E99F4E37C7365658113B8 /* Pods_SEDaily_IOSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF0C9F051C6C2F8638D2ABF8 /* Pods_SEDaily_IOSTests.framework */; }; 24412438C753126FDDD37CBF /* Pods_SEDaily_IOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3651E5B02B83E392546E167 /* Pods_SEDaily_IOS.framework */; }; + 43086E3122A67F4B00B8B65B /* ProfileTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43086E3022A67F4B00B8B65B /* ProfileTableViewDataSource.swift */; }; + 43086E3322A7AD2400B8B65B /* SummaryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43086E3222A7AD2400B8B65B /* SummaryCell.swift */; }; + 43086E3522A7C7CB00B8B65B /* User+ProfileViewController.Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43086E3422A7C7CB00B8B65B /* User+ProfileViewController.Section.swift */; }; + 43086E3722A85C7A00B8B65B /* AvatarCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43086E3622A85C7A00B8B65B /* AvatarCell.swift */; }; + 43086E3B22A8EAF800B8B65B /* SwitchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43086E3A22A8EAF800B8B65B /* SwitchCell.swift */; }; + 430FF9C922B2FEF70033A15A /* URLSchemaHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430FF9C822B2FEF60033A15A /* URLSchemaHelper.swift */; }; + 431135D422AA2E2B00BFC910 /* SeparatorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431135D322AA2E2B00BFC910 /* SeparatorCell.swift */; }; + 431DE891228D619D00FACA74 /* PostsForTopicCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431DE890228D619C00FACA74 /* PostsForTopicCollectionViewController.swift */; }; + 433A866522DDD3510055F931 /* RemoteCommandDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433A866422DDD3510055F931 /* RemoteCommandDataSource.swift */; }; + 433A866722DDD3720055F931 /* RemoteCommandManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433A866622DDD3720055F931 /* RemoteCommandManager.swift */; }; + 43476C9722DDA8A90032ABD5 /* PlaybackSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476C9622DDA8A90032ABD5 /* PlaybackSpeed.swift */; }; + 43476C9D22DDBD770032ABD5 /* ForumThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476C9C22DDBD770032ABD5 /* ForumThread.swift */; }; + 43476C9F22DDBDB90032ABD5 /* ForumThreadLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476C9E22DDBDB90032ABD5 /* ForumThreadLite.swift */; }; + 43476CA222DDD1AB0032ABD5 /* AssetPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476CA122DDD1AB0032ABD5 /* AssetPlayer.swift */; }; + 43476CA422DDD1D20032ABD5 /* Asset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476CA322DDD1D20032ABD5 /* Asset.swift */; }; + 43476CA622DDD2190032ABD5 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43476CA522DDD2190032ABD5 /* PlayerView.swift */; }; + 4359B94422C73B4C009704F8 /* KoalaTeaFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4359B94322C73B4C009704F8 /* KoalaTeaFlowLayout.swift */; }; + 4359B94622C7494D009704F8 /* AudioPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4359B94522C7494D009704F8 /* AudioPlayerView.swift */; }; + 435BAF3A22783B7C00762891 /* EpisodeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435BAF3922783B7C00762891 /* EpisodeViewController.swift */; }; + 435BAF3C2278430900762891 /* EpisodeHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435BAF3B2278430900762891 /* EpisodeHeaderCell.swift */; }; + 436317D922C38B5F0091DA8C /* MainFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436317D822C38B5F0091DA8C /* MainFlowCoordinator.swift */; }; + 436317DC22C38C590091DA8C /* RootViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436317DB22C38C590091DA8C /* RootViewController.swift */; }; + 436317DE22C38DB50091DA8C /* MainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436317DD22C38DB50091DA8C /* MainTabBarController.swift */; }; + 436317E022C3993F0091DA8C /* OverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436317DF22C3993F0091DA8C /* OverlayViewController.swift */; }; + 436317E422C39A8B0091DA8C /* StateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436317E322C39A8A0091DA8C /* StateController.swift */; }; + 4381367D229FFCC4008043B2 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381367C229FFCC4008043B2 /* UIColor+Extensions.swift */; }; + 4398EA3022AE58BF002F22F0 /* LabelCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4398EA2F22AE58BF002F22F0 /* LabelCell.swift */; }; + 439BCC6C22802EDC008D808B /* CurrentlyPlaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BCC6B22802EDC008D808B /* CurrentlyPlaying.swift */; }; + 439BCC6E2280CD6B008D808B /* CommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BCC6D2280CD6B008D808B /* CommentCell.swift */; }; + 439BCC7522818F9D008D808B /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BCC7422818F9D008D808B /* ProfileViewController.swift */; }; + 43A680D02270718600A639A9 /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A680CF2270718600A639A9 /* Debouncer.swift */; }; + 43A680D222707DC000A639A9 /* DownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A680D122707DC000A639A9 /* DownloadService.swift */; }; + 43A680D42270803700A639A9 /* BookmarkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A680D32270803700A639A9 /* BookmarkService.swift */; }; + 43AA2C912268530C0050668B /* ItemCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA2C902268530C0050668B /* ItemCollectionViewCell.swift */; }; + 43AA2C962268AC7B0050668B /* Roboto-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C932268AC7B0050668B /* Roboto-Light.ttf */; }; + 43AA2C972268AC7B0050668B /* Roboto-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C942268AC7B0050668B /* Roboto-Bold.ttf */; }; + 43AA2C982268AC7B0050668B /* Roboto-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C952268AC7B0050668B /* Roboto-Regular.ttf */; }; + 43AA2C9B2268AEE50050668B /* OpenSans-Light.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C992268AEE50050668B /* OpenSans-Light.ttf */; }; + 43AA2C9E2268B1FD0050668B /* OpenSans-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C9D2268B1FD0050668B /* OpenSans-Regular.ttf */; }; + 43AA2CA02268B2660050668B /* OpenSans-Semibold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 43AA2C9F2268B2660050668B /* OpenSans-Semibold.ttf */; }; + 43AD7117228AF08B00E930BD /* TagsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD7116228AF08B00E930BD /* TagsCell.swift */; }; + 43AD7119228B05A400E930BD /* Topic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD7118228B05A400E930BD /* Topic.swift */; }; + 43BA7BF8228452A4000E1171 /* NotificationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BA7BF7228452A4000E1171 /* NotificationsController.swift */; }; + 43C0C76222798635007A92E7 /* ActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C0C76122798635007A92E7 /* ActionView.swift */; }; + 43C0C764227AE7D1007A92E7 /* WebViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C0C763227AE7D1007A92E7 /* WebViewCell.swift */; }; + 43CE140C225F7EF700B57CFA /* PlayProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE140B225F7EF700B57CFA /* PlayProgress.swift */; }; + 43CE140E226070FB00B57CFA /* PlayProgressModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */; }; + 43CE451E2293E85D0048426A /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE451D2293E85C0048426A /* Haptics.swift */; }; + 43CE45212293ECFC0048426A /* DownloadsCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE45202293ECFC0048426A /* DownloadsCollectionViewController.swift */; }; + 43CE45232293F14B0048426A /* DownloadsViewModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE45222293F14B0048426A /* DownloadsViewModelController.swift */; }; + 43CE8E0D2271AFC000DD990F /* HtmlHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE8E0C2271AFC000DD990F /* HtmlHelper.swift */; }; + 43F4591F226F27BB00641705 /* UpvoteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F4591E226F27BA00641705 /* UpvoteService.swift */; }; + 96004ACE20A9E0030017230F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 96004ACD20A9E0030017230F /* GoogleService-Info.plist */; }; + 960BFEBB202109800073DAB2 /* AskForReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEBA202109800073DAB2 /* AskForReview.swift */; }; + 960BFEBF20226B620073DAB2 /* CommentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */; }; + 960BFEC320239F0E0073DAB2 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC220239F0E0073DAB2 /* Comment.swift */; }; + 960BFEC52023C1330073DAB2 /* CommentsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */; }; + 9610313E20A353B400A2D2D5 /* AnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */; }; + 962F5365201E3C7900897A6E /* RelatedLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */; }; + 964090BF2093B97D00CFF5C4 /* PodcastLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */; }; + 965E165820ABD74900F2E4E4 /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 965E165720ABD74900F2E4E4 /* FeedItem.swift */; }; + 96600312201CEBBD00997795 /* RelatedLinks.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 96600311201CEBBD00997795 /* RelatedLinks.storyboard */; }; + 9662FC0A20AF21FA00CFA8DF /* BaseFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */; }; + 9672AA52202929C90020981F /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9672AA51202929C90020981F /* Author.swift */; }; + 96BD1C88208E9497006C5E1D /* TestHookBool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96BD1C87208E9497006C5E1D /* TestHookBool.swift */; }; + 96F35745201BFB0200E8B6E9 /* RelatedLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */; }; + C0109D992013ECE0008BDA69 /* upvote_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C0109D982013ECE0008BDA69 /* upvote_success.json */; }; + C0109D9B2013F064008BDA69 /* upvote_failure.json in Resources */ = {isa = PBXBuildFile; fileRef = C0109D9A2013F064008BDA69 /* upvote_failure.json */; }; + C0109D9E2013F629008BDA69 /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109D9D2013F629008BDA69 /* LoginTests.swift */; }; + C0109DA02013F6A5008BDA69 /* RegisterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */; }; + C0109DA22013F6D8008BDA69 /* PostsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109DA12013F6D8008BDA69 /* PostsTests.swift */; }; + C015CB17201132DE00C6FD82 /* register_userexists.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB16201132DE00C6FD82 /* register_userexists.json */; }; + C015CB192011373000C6FD82 /* register_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB182011373000C6FD82 /* register_success.json */; }; + C015CB1B2011388C00C6FD82 /* register_emptyusernamepass.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */; }; + C015CB1E20113CAB00C6FD82 /* getPostsWith_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */; }; + C04C37FD200E94AE00C6EFC6 /* login_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C37FC200E94AE00C6EFC6 /* login_success.json */; }; + C04C3800200EA6F400C6EFC6 /* login_wrongpass.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */; }; + C04C3802200EA99200C6EFC6 /* login_nonexistinguser.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */; }; + C0590D78200AC2A500A00E4D /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0590D77200AC2A500A00E4D /* NetworkService.swift */; }; + C0590D7A200AD36500A00E4D /* VotingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0590D79200AD36400A00E4D /* VotingTests.swift */; }; + C05DA1D92013FA34003C631F /* getPosts_topPosts.json in Resources */ = {isa = PBXBuildFile; fileRef = C05DA1D82013FA34003C631F /* getPosts_topPosts.json */; }; + C079B6541FCA07F800B4B304 /* HelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C079B6531FCA07F800B4B304 /* HelpersTests.swift */; }; + C0E64BB41FCB3F9100753AF0 /* UserModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */; }; + C0E64BB71FCB406000753AF0 /* UserDefaultsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -58,13 +150,11 @@ /* Begin PBXFileReference section */ 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastPageViewController.swift; sourceTree = ""; }; - 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTableViewController.swift; sourceTree = ""; }; - 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastTableViewCell.swift; sourceTree = ""; }; + 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineDownloadsManager.swift; sourceTree = ""; }; + 161F3DE51F61F73D00A8F825 /* SearchCollectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchCollectionViewController.swift; sourceTree = ""; }; + 162FFD3F1FBE200E0026288D /* DiskKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskKeys.swift; sourceTree = ""; }; 16307FC91F8FDD02001783CB /* Podcast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Podcast.swift; sourceTree = ""; }; 16307FCB1F8FEE3A001783CB /* PodcastViewModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastViewModelController.swift; sourceTree = ""; }; - 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContainerViewController.swift; sourceTree = ""; }; - 164C71041F021AC8003803BC /* CustomTabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTabViewController.swift; sourceTree = ""; }; - 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastCollectionViewCell.swift; sourceTree = ""; }; 164FE9E01F02EE67009419CA /* LoginViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 164FE9E41F02EE83009419CA /* API.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; 164FE9E61F02F02F009419CA /* UserModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserModel.swift; sourceTree = ""; }; @@ -74,7 +164,9 @@ 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerExtension.swift; sourceTree = ""; }; 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCollectionViewController.swift; sourceTree = ""; }; 166036201F266FF300A22B7B /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; - 167AFAB61F043F1100A1332F /* HeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; + 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSubscriptionViewController.swift; sourceTree = ""; }; + 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionModel.swift; sourceTree = ""; }; + 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusViewController.swift; sourceTree = ""; }; 1686FBFD1F009EC00088A6C1 /* SEDaily-IOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SEDaily-IOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 1686FC001F009EC00088A6C1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 1686FC051F009EC00088A6C1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -82,28 +174,120 @@ 1686FC0A1F009EC00088A6C1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 1686FC0C1F009EC00088A6C1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 1686FC111F009EC00088A6C1 /* SEDaily-IOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SEDaily-IOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 1686FC151F009EC00088A6C1 /* SEDaily_IOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEDaily_IOSTests.swift; sourceTree = ""; }; + 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEDailyIOSTests.swift; sourceTree = ""; }; 1686FC171F009EC00088A6C1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIStripeExtension.swift; sourceTree = ""; }; 169806A51F8EF08F0075D8AD /* L10nEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = L10nEnum.swift; sourceTree = ""; }; 16AD3E091F9B138D0084C545 /* PodcastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastViewModel.swift; sourceTree = ""; }; 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterObject.swift; sourceTree = ""; }; 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastDataSource.swift; sourceTree = ""; }; - 16B147B01F16BF9C00433A42 /* AudioViewManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioViewManager.swift; sourceTree = ""; }; - 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastDetailViewController.swift; sourceTree = ""; }; - 16CE69901F9807CF0057BAC3 /* PodcastDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastDescriptionView.swift; sourceTree = ""; }; 16D67C491F33AC620065E838 /* AnswersTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnswersTracker.swift; sourceTree = ""; }; - 16D766B91F06B4850066C143 /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = ""; }; 16F3A1B91F90918D00364709 /* PodcastRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRepository.swift; sourceTree = ""; }; 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCollectionView.swift; sourceTree = ""; }; + 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookEventTableViewCell.swift; sourceTree = ""; }; + 1E286DE41FE4C63200644C1D /* TestHookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookManager.swift; sourceTree = ""; }; + 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookEvent.swift; sourceTree = ""; }; + 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHookBoolTableViewCell.swift; sourceTree = ""; }; 1E44AEFF1F87ACF500221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/LaunchScreen.strings; sourceTree = ""; }; 1E44AF001F87ACF500221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; 1E44AF061F87B09100221B22 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 1E44AF081F87B0A700221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; + 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BookmarkCollectionViewController.swift; path = Bookmark/BookmarkCollectionViewController.swift; sourceTree = ""; }; + 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BookmarkViewModelController.swift; path = Bookmark/BookmarkViewModelController.swift; sourceTree = ""; }; + 1EABD8F11FB430AA00959859 /* Debug.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Debug.storyboard; sourceTree = ""; }; + 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugTabViewController.swift; sourceTree = ""; }; + 1EABD8F31FB430AA00959859 /* TestHook.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHook.swift; sourceTree = ""; }; + 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StateBookmarkView.swift; path = Bookmark/StateBookmarkView.swift; sourceTree = ""; }; + 43086E3022A67F4B00B8B65B /* ProfileTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewDataSource.swift; sourceTree = ""; }; + 43086E3222A7AD2400B8B65B /* SummaryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryCell.swift; sourceTree = ""; }; + 43086E3422A7C7CB00B8B65B /* User+ProfileViewController.Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User+ProfileViewController.Section.swift"; sourceTree = ""; }; + 43086E3622A85C7A00B8B65B /* AvatarCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCell.swift; sourceTree = ""; }; + 43086E3A22A8EAF800B8B65B /* SwitchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchCell.swift; sourceTree = ""; }; + 430FF9C822B2FEF60033A15A /* URLSchemaHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSchemaHelper.swift; sourceTree = ""; }; + 431135D322AA2E2B00BFC910 /* SeparatorCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SeparatorCell.swift; sourceTree = ""; }; + 431DE890228D619C00FACA74 /* PostsForTopicCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsForTopicCollectionViewController.swift; sourceTree = ""; }; + 433A866422DDD3510055F931 /* RemoteCommandDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandDataSource.swift; sourceTree = ""; }; + 433A866622DDD3720055F931 /* RemoteCommandManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandManager.swift; sourceTree = ""; }; + 43476C9622DDA8A90032ABD5 /* PlaybackSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackSpeed.swift; sourceTree = ""; }; + 43476C9C22DDBD770032ABD5 /* ForumThread.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForumThread.swift; sourceTree = ""; }; + 43476C9E22DDBDB90032ABD5 /* ForumThreadLite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForumThreadLite.swift; sourceTree = ""; }; + 43476CA122DDD1AB0032ABD5 /* AssetPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPlayer.swift; sourceTree = ""; }; + 43476CA322DDD1D20032ABD5 /* Asset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asset.swift; sourceTree = ""; }; + 43476CA522DDD2190032ABD5 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; + 4359B94322C73B4C009704F8 /* KoalaTeaFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoalaTeaFlowLayout.swift; sourceTree = ""; }; + 4359B94522C7494D009704F8 /* AudioPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerView.swift; sourceTree = ""; }; + 435BAF3922783B7C00762891 /* EpisodeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeViewController.swift; sourceTree = ""; }; + 435BAF3B2278430900762891 /* EpisodeHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeHeaderCell.swift; sourceTree = ""; }; + 436317D822C38B5F0091DA8C /* MainFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlowCoordinator.swift; sourceTree = ""; }; + 436317DB22C38C590091DA8C /* RootViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewController.swift; sourceTree = ""; }; + 436317DD22C38DB50091DA8C /* MainTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarController.swift; sourceTree = ""; }; + 436317DF22C3993F0091DA8C /* OverlayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverlayViewController.swift; sourceTree = ""; }; + 436317E322C39A8A0091DA8C /* StateController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateController.swift; sourceTree = ""; }; + 4381367C229FFCC4008043B2 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; + 4398EA2F22AE58BF002F22F0 /* LabelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelCell.swift; sourceTree = ""; }; + 439BCC6B22802EDC008D808B /* CurrentlyPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentlyPlaying.swift; sourceTree = ""; }; + 439BCC6D2280CD6B008D808B /* CommentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentCell.swift; sourceTree = ""; }; + 439BCC7422818F9D008D808B /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; + 43A680CF2270718600A639A9 /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = ""; }; + 43A680D122707DC000A639A9 /* DownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadService.swift; sourceTree = ""; }; + 43A680D32270803700A639A9 /* BookmarkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkService.swift; sourceTree = ""; }; + 43AA2C902268530C0050668B /* ItemCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCollectionViewCell.swift; sourceTree = ""; }; + 43AA2C932268AC7B0050668B /* Roboto-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Light.ttf"; sourceTree = ""; }; + 43AA2C942268AC7B0050668B /* Roboto-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Bold.ttf"; sourceTree = ""; }; + 43AA2C952268AC7B0050668B /* Roboto-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Roboto-Regular.ttf"; sourceTree = ""; }; + 43AA2C992268AEE50050668B /* OpenSans-Light.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "OpenSans-Light.ttf"; sourceTree = ""; }; + 43AA2C9D2268B1FD0050668B /* OpenSans-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "OpenSans-Regular.ttf"; sourceTree = ""; }; + 43AA2C9F2268B2660050668B /* OpenSans-Semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "OpenSans-Semibold.ttf"; sourceTree = ""; }; + 43AD7116228AF08B00E930BD /* TagsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsCell.swift; sourceTree = ""; }; + 43AD7118228B05A400E930BD /* Topic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Topic.swift; sourceTree = ""; }; + 43BA7BF7228452A4000E1171 /* NotificationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsController.swift; sourceTree = ""; }; + 43C0C76122798635007A92E7 /* ActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionView.swift; sourceTree = ""; }; + 43C0C763227AE7D1007A92E7 /* WebViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewCell.swift; sourceTree = ""; }; + 43CE140B225F7EF700B57CFA /* PlayProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayProgress.swift; sourceTree = ""; }; + 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayProgressModelController.swift; sourceTree = ""; }; + 43CE451D2293E85C0048426A /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; + 43CE45202293ECFC0048426A /* DownloadsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsCollectionViewController.swift; sourceTree = ""; }; + 43CE45222293F14B0048426A /* DownloadsViewModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModelController.swift; sourceTree = ""; }; + 43CE8E0C2271AFC000DD990F /* HtmlHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HtmlHelper.swift; sourceTree = ""; }; + 43F4591E226F27BA00641705 /* UpvoteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpvoteService.swift; sourceTree = ""; }; 675FBEB71FD81FBA8767227A /* Pods-SEDaily-IOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS.release.xcconfig"; sourceTree = ""; }; + 96004ACD20A9E0030017230F /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 960BFEBA202109800073DAB2 /* AskForReview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AskForReview.swift; sourceTree = ""; }; + 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsViewController.swift; sourceTree = ""; }; + 960BFEC220239F0E0073DAB2 /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsResponse.swift; sourceTree = ""; }; + 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHelper.swift; sourceTree = ""; }; + 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksViewController.swift; sourceTree = ""; }; + 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLite.swift; sourceTree = ""; }; + 965E165720ABD74900F2E4E4 /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; + 96600311201CEBBD00997795 /* RelatedLinks.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = RelatedLinks.storyboard; sourceTree = ""; }; + 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseFeedItem.swift; sourceTree = ""; }; + 9672AA51202929C90020981F /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = ""; }; + 96BD1C87208E9497006C5E1D /* TestHookBool.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHookBool.swift; sourceTree = ""; }; + 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLink.swift; sourceTree = ""; }; 995F64385352A79BA0DF7DEB /* Pods-SEDaily-IOSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOSTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests.debug.xcconfig"; sourceTree = ""; }; 9FDA28C254F62BB468401B75 /* Pods-SEDaily-IOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS.debug.xcconfig"; sourceTree = ""; }; A2EFA433B346D70E958FFC73 /* Pods-SEDaily-IOSTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOSTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests.release.xcconfig"; sourceTree = ""; }; A3651E5B02B83E392546E167 /* Pods_SEDaily_IOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SEDaily_IOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C0109D982013ECE0008BDA69 /* upvote_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = upvote_success.json; sourceTree = ""; }; + C0109D9A2013F064008BDA69 /* upvote_failure.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = upvote_failure.json; sourceTree = ""; }; + C0109D9D2013F629008BDA69 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = ""; }; + C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterTests.swift; sourceTree = ""; }; + C0109DA12013F6D8008BDA69 /* PostsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsTests.swift; sourceTree = ""; }; + C015CB16201132DE00C6FD82 /* register_userexists.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_userexists.json; sourceTree = ""; }; + C015CB182011373000C6FD82 /* register_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_success.json; sourceTree = ""; }; + C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_emptyusernamepass.json; sourceTree = ""; }; + C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = getPostsWith_success.json; sourceTree = ""; }; + C04C37FC200E94AE00C6EFC6 /* login_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_success.json; sourceTree = ""; }; + C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_wrongpass.json; sourceTree = ""; }; + C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_nonexistinguser.json; sourceTree = ""; }; + C0590D77200AC2A500A00E4D /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + C0590D79200AD36400A00E4D /* VotingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingTests.swift; sourceTree = ""; }; + C05DA1D82013FA34003C631F /* getPosts_topPosts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = getPosts_topPosts.json; sourceTree = ""; }; + C079B6531FCA07F800B4B304 /* HelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpersTests.swift; sourceTree = ""; }; + C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModelTests.swift; sourceTree = ""; }; + C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsMock.swift; sourceTree = ""; }; DF0C9F051C6C2F8638D2ABF8 /* Pods_SEDaily_IOSTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SEDaily_IOSTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -127,9 +311,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 017074222071938D002E6E3C /* Notifications */ = { + isa = PBXGroup; + children = ( + 43BA7BF7228452A4000E1171 /* NotificationsController.swift */, + ); + name = Notifications; + sourceTree = ""; + }; + 161791FA1FC4DA5800A1287E /* Offline Downloads */ = { + isa = PBXGroup; + children = ( + 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */, + ); + name = "Offline Downloads"; + sourceTree = ""; + }; 16307FC81F8FDCE4001783CB /* PodcastModels */ = { isa = PBXGroup; children = ( + 43AD7118228B05A400E930BD /* Topic.swift */, 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */, 16307FC91F8FDD02001783CB /* Podcast.swift */, 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */, @@ -145,8 +346,8 @@ children = ( 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */, 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */, - 16307FC81F8FDCE4001783CB /* PodcastModels */, 16AD3E001F9A975F0084C545 /* PodcastDetail */, + 16307FC81F8FDCE4001783CB /* PodcastModels */, ); name = Podcasts; sourceTree = ""; @@ -154,6 +355,9 @@ 164FE9E21F02EE71009419CA /* CommonModels */ = { isa = PBXGroup; children = ( + 961A378B208FAFC30050EF80 /* Feed */, + 96B6168520C0861300DF3CF6 /* Thread */, + 961A378A208FAF3E0050EF80 /* Author */, 164FE9E61F02F02F009419CA /* UserModel.swift */, ); name = CommonModels; @@ -162,12 +366,23 @@ 164FE9E31F02EE77009419CA /* Helpers */ = { isa = PBXGroup; children = ( + 162FFD3F1FBE200E0026288D /* DiskKeys.swift */, 164FE9E81F02F049009419CA /* Stylesheet.swift */, 164FE9EA1F02F340009419CA /* Helpers.swift */, 164FE9EC1F02F7E2009419CA /* UIButtonExtension.swift */, 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */, 166036201F266FF300A22B7B /* Notifications.swift */, 16D67C491F33AC620065E838 /* AnswersTracker.swift */, + 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */, + 960BFEBA202109800073DAB2 /* AskForReview.swift */, + 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */, + 43A680CF2270718600A639A9 /* Debouncer.swift */, + 43CE8E0C2271AFC000DD990F /* HtmlHelper.swift */, + 439BCC6B22802EDC008D808B /* CurrentlyPlaying.swift */, + 43CE451D2293E85C0048426A /* Haptics.swift */, + 4381367C229FFCC4008043B2 /* UIColor+Extensions.swift */, + 430FF9C822B2FEF60033A15A /* URLSchemaHelper.swift */, + 43476C9622DDA8A90032ABD5 /* PlaybackSpeed.swift */, ); name = Helpers; sourceTree = ""; @@ -195,21 +410,35 @@ 1686FBFF1F009EC00088A6C1 /* SEDaily-IOS */ = { isa = PBXGroup; children = ( - 169806A41F8EF08F0075D8AD /* Constants */, - 1686FC0C1F009EC00088A6C1 /* Info.plist */, - 164FE9E41F02EE83009419CA /* API.swift */, 1686FC001F009EC00088A6C1 /* AppDelegate.swift */, - 1E44AF051F87B08D00221B22 /* Localizable.strings */, - 1686FC071F009EC00088A6C1 /* Assets.xcassets */, + 436317DA22C38C050091DA8C /* Screens and navigation flow */, + 43476CA022DDBF4C0032ABD5 /* Network */, + 43CE451F2293ECCB0048426A /* Downloads */, + 43C3B92D228C434A00D884FD /* Topic */, + 439BCC6F22818F32008D808B /* Profile */, + 43F4591D226F279F00641705 /* Services */, + 960BFEBD20226B420073DAB2 /* Comments */, + 96600306201CE9F900997795 /* RelatedLinks */, + 017074222071938D002E6E3C /* Notifications */, + 161791FA1FC4DA5800A1287E /* Offline Downloads */, + 1688CB4A2006BDCE00440095 /* Stripe */, 16AD3E011F9B06480084C545 /* Audio */, 16AD3E071F9B07520084C545 /* Auth */, - 16AD3E061F9B07320084C545 /* CommonVCs */, + 164FE9DF1F02EE4E009419CA /* Podcasts */, + 16AD3E031F9B06950084C545 /* Search */, + 1E2BFDCE2017D91200E6DE0A /* Bookmark */, 16AD3E051F9B07160084C545 /* CommonCells */, 164FE9E21F02EE71009419CA /* CommonModels */, + 16AD3E061F9B07320084C545 /* CommonVCs */, + 169806A41F8EF08F0075D8AD /* Constants */, 16AD3E081F9B07A70084C545 /* Core */, + 1EABD8F01FB430AA00959859 /* Debug */, 164FE9E31F02EE77009419CA /* Helpers */, - 164FE9DF1F02EE4E009419CA /* Podcasts */, - 16AD3E031F9B06950084C545 /* Search */, + 439913D722C9F0570061EE6E /* Vendor */, + 1686FC0C1F009EC00088A6C1 /* Info.plist */, + 96004ACD20A9E0030017230F /* GoogleService-Info.plist */, + 1E44AF051F87B08D00221B22 /* Localizable.strings */, + 1686FC071F009EC00088A6C1 /* Assets.xcassets */, ); path = "SEDaily-IOS"; sourceTree = ""; @@ -217,12 +446,27 @@ 1686FC141F009EC00088A6C1 /* SEDaily-IOSTests */ = { isa = PBXGroup; children = ( - 1686FC151F009EC00088A6C1 /* SEDaily_IOSTests.swift */, + C0109D9C2013F538008BDA69 /* APITests */, + C0E64BB51FCB404B00753AF0 /* Mocks */, + 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */, 1686FC171F009EC00088A6C1 /* Info.plist */, + C079B6531FCA07F800B4B304 /* HelpersTests.swift */, + C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */, ); path = "SEDaily-IOSTests"; sourceTree = ""; }; + 1688CB4A2006BDCE00440095 /* Stripe */ = { + isa = PBXGroup; + children = ( + 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */, + 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */, + 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */, + 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */, + ); + name = Stripe; + sourceTree = ""; + }; 169806A41F8EF08F0075D8AD /* Constants */ = { isa = PBXGroup; children = ( @@ -234,9 +478,11 @@ 16AD3E001F9A975F0084C545 /* PodcastDetail */ = { isa = PBXGroup; children = ( - 167AFAB61F043F1100A1332F /* HeaderView.swift */, - 16CE69901F9807CF0057BAC3 /* PodcastDescriptionView.swift */, - 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */, + 435BAF3922783B7C00762891 /* EpisodeViewController.swift */, + 435BAF3B2278430900762891 /* EpisodeHeaderCell.swift */, + 43C0C763227AE7D1007A92E7 /* WebViewCell.swift */, + 43C0C76122798635007A92E7 /* ActionView.swift */, + 43AD7116228AF08B00E930BD /* TagsCell.swift */, ); name = PodcastDetail; sourceTree = ""; @@ -244,8 +490,10 @@ 16AD3E011F9B06480084C545 /* Audio */ = { isa = PBXGroup; children = ( - 16B147B01F16BF9C00433A42 /* AudioViewManager.swift */, - 16D766B91F06B4850066C143 /* AudioView.swift */, + 436317DF22C3993F0091DA8C /* OverlayViewController.swift */, + 4359B94522C7494D009704F8 /* AudioPlayerView.swift */, + 43CE140B225F7EF700B57CFA /* PlayProgress.swift */, + 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */, ); name = Audio; sourceTree = ""; @@ -253,7 +501,7 @@ 16AD3E031F9B06950084C545 /* Search */ = { isa = PBXGroup; children = ( - 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */, + 161F3DE51F61F73D00A8F825 /* SearchCollectionViewController.swift */, ); name = Search; sourceTree = ""; @@ -261,8 +509,7 @@ 16AD3E051F9B07160084C545 /* CommonCells */ = { isa = PBXGroup; children = ( - 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */, - 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */, + 43AA2C902268530C0050668B /* ItemCollectionViewCell.swift */, ); name = CommonCells; sourceTree = ""; @@ -270,9 +517,7 @@ 16AD3E061F9B07320084C545 /* CommonVCs */ = { isa = PBXGroup; children = ( - 164C71041F021AC8003803BC /* CustomTabViewController.swift */, 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */, - 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */, ); name = CommonVCs; sourceTree = ""; @@ -294,6 +539,136 @@ name = Core; sourceTree = ""; }; + 1E2BFDCE2017D91200E6DE0A /* Bookmark */ = { + isa = PBXGroup; + children = ( + 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */, + 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */, + 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */, + ); + name = Bookmark; + sourceTree = ""; + }; + 1EABD8F01FB430AA00959859 /* Debug */ = { + isa = PBXGroup; + children = ( + 1EABD8F11FB430AA00959859 /* Debug.storyboard */, + 96BD1C87208E9497006C5E1D /* TestHookBool.swift */, + 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */, + 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */, + 1EABD8F31FB430AA00959859 /* TestHook.swift */, + 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */, + 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */, + 1E286DE41FE4C63200644C1D /* TestHookManager.swift */, + ); + path = Debug; + sourceTree = ""; + }; + 433A866322DDD3390055F931 /* RemoteCommand */ = { + isa = PBXGroup; + children = ( + 433A866422DDD3510055F931 /* RemoteCommandDataSource.swift */, + 433A866622DDD3720055F931 /* RemoteCommandManager.swift */, + ); + name = RemoteCommand; + sourceTree = ""; + }; + 43476CA022DDBF4C0032ABD5 /* Network */ = { + isa = PBXGroup; + children = ( + C0590D77200AC2A500A00E4D /* NetworkService.swift */, + 164FE9E41F02EE83009419CA /* API.swift */, + ); + name = Network; + sourceTree = ""; + }; + 436317D722C38B1B0091DA8C /* Coordinators */ = { + isa = PBXGroup; + children = ( + 436317D822C38B5F0091DA8C /* MainFlowCoordinator.swift */, + ); + name = Coordinators; + sourceTree = ""; + }; + 436317DA22C38C050091DA8C /* Screens and navigation flow */ = { + isa = PBXGroup; + children = ( + 436317D722C38B1B0091DA8C /* Coordinators */, + 436317DB22C38C590091DA8C /* RootViewController.swift */, + 436317DD22C38DB50091DA8C /* MainTabBarController.swift */, + 436317E322C39A8A0091DA8C /* StateController.swift */, + ); + name = "Screens and navigation flow"; + sourceTree = ""; + }; + 439913D722C9F0570061EE6E /* Vendor */ = { + isa = PBXGroup; + children = ( + 433A866322DDD3390055F931 /* RemoteCommand */, + 43AA2C922268AC3B0050668B /* fonts */, + 4359B94322C73B4C009704F8 /* KoalaTeaFlowLayout.swift */, + 43476CA122DDD1AB0032ABD5 /* AssetPlayer.swift */, + 43476CA322DDD1D20032ABD5 /* Asset.swift */, + 43476CA522DDD2190032ABD5 /* PlayerView.swift */, + ); + name = Vendor; + sourceTree = ""; + }; + 439BCC6F22818F32008D808B /* Profile */ = { + isa = PBXGroup; + children = ( + 439BCC7422818F9D008D808B /* ProfileViewController.swift */, + 43086E3022A67F4B00B8B65B /* ProfileTableViewDataSource.swift */, + 43086E3222A7AD2400B8B65B /* SummaryCell.swift */, + 43086E3422A7C7CB00B8B65B /* User+ProfileViewController.Section.swift */, + 43086E3622A85C7A00B8B65B /* AvatarCell.swift */, + 43086E3A22A8EAF800B8B65B /* SwitchCell.swift */, + 431135D322AA2E2B00BFC910 /* SeparatorCell.swift */, + 4398EA2F22AE58BF002F22F0 /* LabelCell.swift */, + ); + name = Profile; + sourceTree = ""; + }; + 43AA2C922268AC3B0050668B /* fonts */ = { + isa = PBXGroup; + children = ( + 43AA2C942268AC7B0050668B /* Roboto-Bold.ttf */, + 43AA2C992268AEE50050668B /* OpenSans-Light.ttf */, + 43AA2C932268AC7B0050668B /* Roboto-Light.ttf */, + 43AA2C9F2268B2660050668B /* OpenSans-Semibold.ttf */, + 43AA2C9D2268B1FD0050668B /* OpenSans-Regular.ttf */, + 43AA2C952268AC7B0050668B /* Roboto-Regular.ttf */, + ); + name = fonts; + sourceTree = ""; + }; + 43C3B92D228C434A00D884FD /* Topic */ = { + isa = PBXGroup; + children = ( + 431DE890228D619C00FACA74 /* PostsForTopicCollectionViewController.swift */, + ); + name = Topic; + sourceTree = ""; + }; + 43CE451F2293ECCB0048426A /* Downloads */ = { + isa = PBXGroup; + children = ( + 43CE45202293ECFC0048426A /* DownloadsCollectionViewController.swift */, + 43CE45222293F14B0048426A /* DownloadsViewModelController.swift */, + ); + name = Downloads; + sourceTree = ""; + }; + 43F4591D226F279F00641705 /* Services */ = { + isa = PBXGroup; + children = ( + 43F4591E226F27BA00641705 /* UpvoteService.swift */, + 43A680D122707DC000A639A9 /* DownloadService.swift */, + 43A680D32270803700A639A9 /* BookmarkService.swift */, + ); + name = Services; + sourceTree = ""; + }; 4E74333675FE5899EC260076 /* Pods */ = { isa = PBXGroup; children = ( @@ -305,6 +680,115 @@ name = Pods; sourceTree = ""; }; + 960BFEBD20226B420073DAB2 /* Comments */ = { + isa = PBXGroup; + children = ( + 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */, + 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */, + 960BFEC220239F0E0073DAB2 /* Comment.swift */, + 439BCC6D2280CD6B008D808B /* CommentCell.swift */, + ); + name = Comments; + sourceTree = ""; + }; + 961A378A208FAF3E0050EF80 /* Author */ = { + isa = PBXGroup; + children = ( + 9672AA51202929C90020981F /* Author.swift */, + ); + name = Author; + sourceTree = ""; + }; + 961A378B208FAFC30050EF80 /* Feed */ = { + isa = PBXGroup; + children = ( + 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */, + 965E165720ABD74900F2E4E4 /* FeedItem.swift */, + 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */, + ); + name = Feed; + sourceTree = ""; + }; + 96600306201CE9F900997795 /* RelatedLinks */ = { + isa = PBXGroup; + children = ( + 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */, + 96600311201CEBBD00997795 /* RelatedLinks.storyboard */, + 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */, + ); + name = RelatedLinks; + sourceTree = ""; + }; + 96B6168520C0861300DF3CF6 /* Thread */ = { + isa = PBXGroup; + children = ( + 43476C9E22DDBDB90032ABD5 /* ForumThreadLite.swift */, + 43476C9C22DDBD770032ABD5 /* ForumThread.swift */, + ); + name = Thread; + sourceTree = ""; + }; + C0109D9C2013F538008BDA69 /* APITests */ = { + isa = PBXGroup; + children = ( + C0590D79200AD36400A00E4D /* VotingTests.swift */, + C0109D9D2013F629008BDA69 /* LoginTests.swift */, + C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */, + C0109DA12013F6D8008BDA69 /* PostsTests.swift */, + ); + path = APITests; + sourceTree = ""; + }; + C015CB15201132C600C6FD82 /* register */ = { + isa = PBXGroup; + children = ( + C015CB16201132DE00C6FD82 /* register_userexists.json */, + C015CB182011373000C6FD82 /* register_success.json */, + C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */, + ); + path = register; + sourceTree = ""; + }; + C015CB1C20113C9800C6FD82 /* posts */ = { + isa = PBXGroup; + children = ( + C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */, + C0109D982013ECE0008BDA69 /* upvote_success.json */, + C0109D9A2013F064008BDA69 /* upvote_failure.json */, + C05DA1D82013FA34003C631F /* getPosts_topPosts.json */, + ); + path = posts; + sourceTree = ""; + }; + C04C37FB200E948F00C6EFC6 /* Responses */ = { + isa = PBXGroup; + children = ( + C015CB1C20113C9800C6FD82 /* posts */, + C015CB15201132C600C6FD82 /* register */, + C04C37FE200EA5D000C6EFC6 /* login */, + ); + path = Responses; + sourceTree = ""; + }; + C04C37FE200EA5D000C6EFC6 /* login */ = { + isa = PBXGroup; + children = ( + C04C37FC200E94AE00C6EFC6 /* login_success.json */, + C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */, + C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */, + ); + path = login; + sourceTree = ""; + }; + C0E64BB51FCB404B00753AF0 /* Mocks */ = { + isa = PBXGroup; + children = ( + C04C37FB200E948F00C6EFC6 /* Responses */, + C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; E6ABCD02C9CBA1E6E09EB4DE /* Frameworks */ = { isa = PBXGroup; children = ( @@ -322,13 +806,13 @@ buildConfigurationList = 1686FC1A1F009EC00088A6C1 /* Build configuration list for PBXNativeTarget "SEDaily-IOS" */; buildPhases = ( 6A12E8C003C75BEEE01B2AA0 /* [CP] Check Pods Manifest.lock */, + 169806A21F8EED850075D8AD /* Swiftgen Localize Strings Script */, 1686FBF91F009EC00088A6C1 /* Sources */, 1686FBFA1F009EC00088A6C1 /* Frameworks */, 1686FBFB1F009EC00088A6C1 /* Resources */, AE5F616C12D0339AD2CE40F0 /* [CP] Embed Pods Frameworks */, - 4B6DBB81C87D23ABC98202EB /* [CP] Copy Pods Resources */, 16D67C4B1F33AD1D0065E838 /* ShellScript */, - 169806A21F8EED850075D8AD /* Swiftgen Localize Strings Script */, + 1E884C331FA428D400400781 /* SwiftLint */, ); buildRules = ( ); @@ -348,7 +832,6 @@ 1686FC0E1F009EC00088A6C1 /* Frameworks */, 1686FC0F1F009EC00088A6C1 /* Resources */, 43BA1A9ED42DE22C48E64092 /* [CP] Embed Pods Frameworks */, - 8836A870BF4FA6C98035B298 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -372,7 +855,6 @@ TargetAttributes = { 1686FBFC1F009EC00088A6C1 = { CreatedOnToolsVersion = 8.3.2; - DevelopmentTeam = 6TY8WC8WPP; LastSwiftMigration = 0900; ProvisioningStyle = Automatic; SystemCapabilities = { @@ -391,10 +873,11 @@ }; }; buildConfigurationList = 1686FBF81F009EC00088A6C1 /* Build configuration list for PBXProject "SEDaily-IOS" */; - compatibilityVersion = "Xcode 3.2"; + compatibilityVersion = "Xcode 8.0"; developmentRegion = English; hasScannedForEncodings = 0; knownRegions = ( + English, en, Base, fr, @@ -415,10 +898,19 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 43AA2C972268AC7B0050668B /* Roboto-Bold.ttf in Resources */, + 43AA2C9B2268AEE50050668B /* OpenSans-Light.ttf in Resources */, + 43AA2C962268AC7B0050668B /* Roboto-Light.ttf in Resources */, + 96600312201CEBBD00997795 /* RelatedLinks.storyboard in Resources */, + 43AA2C9E2268B1FD0050668B /* OpenSans-Regular.ttf in Resources */, + 96004ACE20A9E0030017230F /* GoogleService-Info.plist in Resources */, 1686FC0B1F009EC00088A6C1 /* LaunchScreen.storyboard in Resources */, + 43AA2C982268AC7B0050668B /* Roboto-Regular.ttf in Resources */, + 1EABD8F41FB430AB00959859 /* Debug.storyboard in Resources */, 1E44AF031F87B08D00221B22 /* Localizable.strings in Resources */, 1686FC081F009EC00088A6C1 /* Assets.xcassets in Resources */, 1686FC061F009EC00088A6C1 /* Main.storyboard in Resources */, + 43AA2CA02268B2660050668B /* OpenSans-Semibold.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -426,6 +918,16 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C0109D9B2013F064008BDA69 /* upvote_failure.json in Resources */, + C015CB192011373000C6FD82 /* register_success.json in Resources */, + C015CB1B2011388C00C6FD82 /* register_emptyusernamepass.json in Resources */, + C015CB1E20113CAB00C6FD82 /* getPostsWith_success.json in Resources */, + C04C3802200EA99200C6EFC6 /* login_nonexistinguser.json in Resources */, + C04C3800200EA6F400C6EFC6 /* login_wrongpass.json in Resources */, + C04C37FD200E94AE00C6EFC6 /* login_success.json in Resources */, + C0109D992013ECE0008BDA69 /* upvote_success.json in Resources */, + C05DA1D92013FA34003C631F /* getPosts_topPosts.json in Resources */, + C015CB17201132DE00C6FD82 /* register_userexists.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -477,34 +979,42 @@ shellPath = /bin/sh; shellScript = "./Fabric.framework/run f0ec5c4da42e81dc990c190bf79ca81fd9fdcf7d 8f1f2cea85347d8df665cfe1460a1391be0d1c99e81197b80ef43259ec294b0d"; }; - 43BA1A9ED42DE22C48E64092 /* [CP] Embed Pods Frameworks */ = { + 1E884C331FA428D400400781 /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; + name = SwiftLint; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\""; }; - 4B6DBB81C87D23ABC98202EB /* [CP] Copy Pods Resources */ = { + 43BA1A9ED42DE22C48E64092 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Mockingjay/Mockingjay.framework", + "${BUILT_PRODUCTS_DIR}/Nimble/Nimble.framework", + "${BUILT_PRODUCTS_DIR}/Quick/Quick.framework", + "${BUILT_PRODUCTS_DIR}/URITemplate/URITemplate.framework", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Mockingjay.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Nimble.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Quick.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/URITemplate.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 6A12E8C003C75BEEE01B2AA0 /* [CP] Check Pods Manifest.lock */ = { @@ -525,79 +1035,72 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 8836A870BF4FA6C98035B298 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; AE5F616C12D0339AD2CE40F0 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( - "${SRCROOT}/Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh", + "${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh", "${BUILT_PRODUCTS_DIR}/ActiveLabel/ActiveLabel.framework", "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", "${BUILT_PRODUCTS_DIR}/Disk/Disk.framework", + "${BUILT_PRODUCTS_DIR}/Down/Down.framework", "${BUILT_PRODUCTS_DIR}/Eureka/Eureka.framework", + "${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework", "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework", "${BUILT_PRODUCTS_DIR}/KTResponsiveUI/KTResponsiveUI.framework", - "${BUILT_PRODUCTS_DIR}/KoalaTeaFlowLayout/KoalaTeaFlowLayout.framework", - "${BUILT_PRODUCTS_DIR}/KoalaTeaPlayer/KoalaTeaPlayer.framework", + "${BUILT_PRODUCTS_DIR}/Kingfisher/Kingfisher.framework", + "${BUILT_PRODUCTS_DIR}/MBProgressHUD/MBProgressHUD.framework", "${BUILT_PRODUCTS_DIR}/Pageboy/Pageboy.framework", + "${BUILT_PRODUCTS_DIR}/PopupDialog/PopupDialog.framework", "${BUILT_PRODUCTS_DIR}/PureLayout/PureLayout.framework", "${BUILT_PRODUCTS_DIR}/Reusable/Reusable.framework", - "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", - "${BUILT_PRODUCTS_DIR}/SideMenu/SideMenu.framework", "${BUILT_PRODUCTS_DIR}/Skeleton/Skeleton.framework", "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework", - "${BUILT_PRODUCTS_DIR}/SwiftIcons/SwiftIcons.framework", + "${BUILT_PRODUCTS_DIR}/StatefulViewController/StatefulViewController.framework", + "${BUILT_PRODUCTS_DIR}/SwiftMoment/SwiftMoment.framework", "${BUILT_PRODUCTS_DIR}/SwiftSoup/SwiftSoup.framework", "${BUILT_PRODUCTS_DIR}/SwifterSwift/SwifterSwift.framework", "${BUILT_PRODUCTS_DIR}/SwiftyBeaver/SwiftyBeaver.framework", "${BUILT_PRODUCTS_DIR}/SwiftyJSON/SwiftyJSON.framework", "${BUILT_PRODUCTS_DIR}/Tabman/Tabman.framework", - "${BUILT_PRODUCTS_DIR}/UIFontComplete/UIFontComplete.framework", + "${BUILT_PRODUCTS_DIR}/Tags/Tags.framework", + "${BUILT_PRODUCTS_DIR}/WaitForIt/WaitForIt.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ActiveLabel.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Disk.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Down.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Eureka.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KTResponsiveUI.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KoalaTeaFlowLayout.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KoalaTeaPlayer.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Kingfisher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MBProgressHUD.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Pageboy.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PopupDialog.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PureLayout.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reusable.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SideMenu.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Skeleton.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftIcons.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/StatefulViewController.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftMoment.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftSoup.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwifterSwift.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyBeaver.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Tabman.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/UIFontComplete.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Tags.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WaitForIt.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -607,36 +1110,102 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 43476C9722DDA8A90032ABD5 /* PlaybackSpeed.swift in Sources */, + 9672AA52202929C90020981F /* Author.swift in Sources */, + 431DE891228D619D00FACA74 /* PostsForTopicCollectionViewController.swift in Sources */, + 439BCC6C22802EDC008D808B /* CurrentlyPlaying.swift in Sources */, + 43CE451E2293E85D0048426A /* Haptics.swift in Sources */, + 96F35745201BFB0200E8B6E9 /* RelatedLink.swift in Sources */, + 965E165820ABD74900F2E4E4 /* FeedItem.swift in Sources */, + 960BFEC320239F0E0073DAB2 /* Comment.swift in Sources */, + 43C0C764227AE7D1007A92E7 /* WebViewCell.swift in Sources */, + 43CE45212293ECFC0048426A /* DownloadsCollectionViewController.swift in Sources */, + 439BCC7522818F9D008D808B /* ProfileViewController.swift in Sources */, + 43476CA622DDD2190032ABD5 /* PlayerView.swift in Sources */, + 4398EA3022AE58BF002F22F0 /* LabelCell.swift in Sources */, + 43A680D222707DC000A639A9 /* DownloadService.swift in Sources */, + 43086E3522A7C7CB00B8B65B /* User+ProfileViewController.Section.swift in Sources */, + 43086E3B22A8EAF800B8B65B /* SwitchCell.swift in Sources */, + 43CE8E0D2271AFC000DD990F /* HtmlHelper.swift in Sources */, + 1671BD58200D361900E6ED3B /* SubscriptionModel.swift in Sources */, + 43086E3722A85C7A00B8B65B /* AvatarCell.swift in Sources */, + 1688CB4C2006BDDA00440095 /* APIStripeExtension.swift in Sources */, + 43A680D02270718600A639A9 /* Debouncer.swift in Sources */, 164FE9E11F02EE67009419CA /* LoginViewController.swift in Sources */, - 1649A78A1F204EC6005C4A6E /* ContainerViewController.swift in Sources */, + 161791FC1FC4DA7200A1287E /* OfflineDownloadsManager.swift in Sources */, + 436317DE22C38DB50091DA8C /* MainTabBarController.swift in Sources */, + C0590D78200AC2A500A00E4D /* NetworkService.swift in Sources */, + 43CE140C225F7EF700B57CFA /* PlayProgress.swift in Sources */, + 43AA2C912268530C0050668B /* ItemCollectionViewCell.swift in Sources */, + 960BFEBB202109800073DAB2 /* AskForReview.swift in Sources */, 164FE9EF1F03065C009419CA /* NavigationControllerExtension.swift in Sources */, 165484551F902D3F005AEA23 /* GeneralCollectionViewController.swift in Sources */, - 161F3DE61F61F73D00A8F825 /* SearchTableViewController.swift in Sources */, - 167AFAB71F043F1100A1332F /* HeaderView.swift in Sources */, + 1E638E561FC794AC00A29BDC /* ProgressIndicator.swift in Sources */, + 43476C9F22DDBDB90032ABD5 /* ForumThreadLite.swift in Sources */, + 161F3DE61F61F73D00A8F825 /* SearchCollectionViewController.swift in Sources */, + 1E2BFDCD2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift in Sources */, 16D67C4A1F33AC620065E838 /* AnswersTracker.swift in Sources */, 164FE9E51F02EE83009419CA /* API.swift in Sources */, 164FE9E91F02F049009419CA /* Stylesheet.swift in Sources */, - 16CE698F1F98029E0057BAC3 /* PodcastDetailViewController.swift in Sources */, + 436317E022C3993F0091DA8C /* OverlayViewController.swift in Sources */, + 960BFEBF20226B620073DAB2 /* CommentsViewController.swift in Sources */, + 43C0C76222798635007A92E7 /* ActionView.swift in Sources */, + 43476CA422DDD1D20032ABD5 /* Asset.swift in Sources */, + 43AD7119228B05A400E930BD /* Topic.swift in Sources */, + 43476C9D22DDBD770032ABD5 /* ForumThread.swift in Sources */, + 43476CA222DDD1AB0032ABD5 /* AssetPlayer.swift in Sources */, + 1671BD5A200D3D7800E6ED3B /* SubscriptionStatusViewController.swift in Sources */, + 96BD1C88208E9497006C5E1D /* TestHookBool.swift in Sources */, + 43CE140E226070FB00B57CFA /* PlayProgressModelController.swift in Sources */, + 1E286DE31FE4C5BA00644C1D /* TestHookEventTableViewCell.swift in Sources */, + 964090BF2093B97D00CFF5C4 /* PodcastLite.swift in Sources */, + 4359B94422C73B4C009704F8 /* KoalaTeaFlowLayout.swift in Sources */, + 43AD7117228AF08B00E930BD /* TagsCell.swift in Sources */, + 436317E422C39A8B0091DA8C /* StateController.swift in Sources */, 16AD3E0C1F9B13EE0084C545 /* FilterObject.swift in Sources */, + 43A680D42270803700A639A9 /* BookmarkService.swift in Sources */, + 1671BD56200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift in Sources */, 16AD3E0A1F9B138D0084C545 /* PodcastViewModel.swift in Sources */, - 16B147B11F16BF9C00433A42 /* AudioViewManager.swift in Sources */, - 164C71051F021AC8003803BC /* CustomTabViewController.swift in Sources */, - 164FE9DC1F02DAB5009419CA /* PodcastCollectionViewCell.swift in Sources */, - 16D766BA1F06B4850066C143 /* AudioView.swift in Sources */, + 43086E3322A7AD2400B8B65B /* SummaryCell.swift in Sources */, + 43F4591F226F27BB00641705 /* UpvoteService.swift in Sources */, + 435BAF3A22783B7C00762891 /* EpisodeViewController.swift in Sources */, + 430FF9C922B2FEF70033A15A /* URLSchemaHelper.swift in Sources */, + 962F5365201E3C7900897A6E /* RelatedLinksViewController.swift in Sources */, + 433A866522DDD3510055F931 /* RemoteCommandDataSource.swift in Sources */, + 43CE45232293F14B0048426A /* DownloadsViewModelController.swift in Sources */, + 1EABD8F51FB430AB00959859 /* DebugTabViewController.swift in Sources */, + 435BAF3C2278430900762891 /* EpisodeHeaderCell.swift in Sources */, + 1E286DE91FE4C65200644C1D /* TestHookEvent.swift in Sources */, + 439BCC6E2280CD6B008D808B /* CommentCell.swift in Sources */, 164FE9EB1F02F340009419CA /* Helpers.swift in Sources */, 16FA84031F8D323700A45D9B /* SkeletonCollectionView.swift in Sources */, + 431135D422AA2E2B00BFC910 /* SeparatorCell.swift in Sources */, 166036211F266FF300A22B7B /* Notifications.swift in Sources */, 169806A61F8EF3970075D8AD /* L10nEnum.swift in Sources */, 01BB1D6D1F29999E004A912E /* PodcastPageViewController.swift in Sources */, - 16CE69911F9807CF0057BAC3 /* PodcastDescriptionView.swift in Sources */, + 436317D922C38B5F0091DA8C /* MainFlowCoordinator.swift in Sources */, + 162FFD401FBE200E0026288D /* DiskKeys.swift in Sources */, 16307FCC1F8FEE3A001783CB /* PodcastViewModelController.swift in Sources */, 16AD3E0E1F9B14130084C545 /* PodcastDataSource.swift in Sources */, 164FE9ED1F02F7E2009419CA /* UIButtonExtension.swift in Sources */, + 1EABD8F61FB430AB00959859 /* TestHook.swift in Sources */, + 9662FC0A20AF21FA00CFA8DF /* BaseFeedItem.swift in Sources */, + 1E286DE51FE4C63200644C1D /* TestHookManager.swift in Sources */, + 960BFEC52023C1330073DAB2 /* CommentsResponse.swift in Sources */, + 1E706BD21FD620B100D44AB2 /* BookmarkCollectionViewController.swift in Sources */, + 43086E3122A67F4B00B8B65B /* ProfileTableViewDataSource.swift in Sources */, + 4381367D229FFCC4008043B2 /* UIColor+Extensions.swift in Sources */, + 436317DC22C38C590091DA8C /* RootViewController.swift in Sources */, 16307FCA1F8FDD02001783CB /* Podcast.swift in Sources */, + 1EB74FFC1FE5E1AF004B733E /* StateBookmarkView.swift in Sources */, 16F3A1BA1F90918D00364709 /* PodcastRepository.swift in Sources */, 1686FC011F009EC00088A6C1 /* AppDelegate.swift in Sources */, - 161F3DEA1F62703100A8F825 /* PodcastTableViewCell.swift in Sources */, + 9610313E20A353B400A2D2D5 /* AnalyticsHelper.swift in Sources */, + 433A866722DDD3720055F931 /* RemoteCommandManager.swift in Sources */, + 4359B94622C7494D009704F8 /* AudioPlayerView.swift in Sources */, 164FE9E71F02F02F009419CA /* UserModel.swift in Sources */, + 43BA7BF8228452A4000E1171 /* NotificationsController.swift in Sources */, + 1E706BD41FD62E0300D44AB2 /* BookmarkViewModelController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -644,7 +1213,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1686FC161F009EC00088A6C1 /* SEDaily_IOSTests.swift in Sources */, + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */, + C0109DA02013F6A5008BDA69 /* RegisterTests.swift in Sources */, + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */, + C0E64BB41FCB3F9100753AF0 /* UserModelTests.swift in Sources */, + C0E64BB71FCB406000753AF0 /* UserDefaultsMock.swift in Sources */, + C0109D9E2013F629008BDA69 /* LoginTests.swift in Sources */, + C079B6541FCA07F800B4B304 /* HelpersTests.swift in Sources */, + C0590D7A200AD36500A00E4D /* VotingTests.swift in Sources */, + C0109DA22013F6D8008BDA69 /* PostsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -806,17 +1383,21 @@ baseConfigurationReference = 9FDA28C254F62BB468401B75 /* Pods-SEDaily-IOS.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; - DEVELOPMENT_TEAM = 6TY8WC8WPP; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 4J2Z86C4XD; INFOPLIST_FILE = "SEDaily-IOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -825,17 +1406,21 @@ baseConfigurationReference = 675FBEB71FD81FBA8767227A /* Pods-SEDaily-IOS.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 13; - DEVELOPMENT_TEAM = 6TY8WC8WPP; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 4J2Z86C4XD; INFOPLIST_FILE = "SEDaily-IOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -843,14 +1428,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = 995F64385352A79BA0DF7DEB /* Pods-SEDaily-IOSTests.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = 6MS9QWALYV; INFOPLIST_FILE = "SEDaily-IOSTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily-IOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SEDaily-IOS.app/SEDaily-IOS"; }; @@ -860,14 +1445,14 @@ isa = XCBuildConfiguration; baseConfigurationReference = A2EFA433B346D70E958FFC73 /* Pods-SEDaily-IOSTests.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; BUNDLE_LOADER = "$(TEST_HOST)"; DEVELOPMENT_TEAM = 6MS9QWALYV; INFOPLIST_FILE = "SEDaily-IOSTests/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily-IOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_SWIFT3_OBJC_INFERENCE = On; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 4.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SEDaily-IOS.app/SEDaily-IOS"; }; diff --git a/SEDaily-IOS.xcodeproj/project.pbxproj.mergesave b/SEDaily-IOS.xcodeproj/project.pbxproj.mergesave new file mode 100644 index 0000000..4751d07 --- /dev/null +++ b/SEDaily-IOS.xcodeproj/project.pbxproj.mergesave @@ -0,0 +1,1322 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 48; + objects = { + +/* Begin PBXBuildFile section */ + 01516A04207310C900E9E743 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01516A03207310C900E9E743 /* NotificationTableViewCell.swift */; }; + 01707421207192C0002E6E3C /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01707420207192C0002E6E3C /* NotificationsTableViewController.swift */; }; + 01BB1D6D1F29999E004A912E /* PodcastPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */; }; + 161791FC1FC4DA7200A1287E /* OfflineDownloadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */; }; + 161F3DE61F61F73D00A8F825 /* SearchTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */; }; + 161F3DEA1F62703100A8F825 /* PodcastTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */; }; + 162FFD401FBE200E0026288D /* DiskKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FFD3F1FBE200E0026288D /* DiskKeys.swift */; }; + 16307FCA1F8FDD02001783CB /* Podcast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16307FC91F8FDD02001783CB /* Podcast.swift */; }; + 16307FCC1F8FEE3A001783CB /* PodcastViewModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16307FCB1F8FEE3A001783CB /* PodcastViewModelController.swift */; }; + 1649A78A1F204EC6005C4A6E /* ContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */; }; + 164C71051F021AC8003803BC /* CustomTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164C71041F021AC8003803BC /* CustomTabViewController.swift */; }; + 164FE9DC1F02DAB5009419CA /* PodcastCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */; }; + 164FE9E11F02EE67009419CA /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E01F02EE67009419CA /* LoginViewController.swift */; }; + 164FE9E51F02EE83009419CA /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E41F02EE83009419CA /* API.swift */; }; + 164FE9E71F02F02F009419CA /* UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E61F02F02F009419CA /* UserModel.swift */; }; + 164FE9E91F02F049009419CA /* Stylesheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9E81F02F049009419CA /* Stylesheet.swift */; }; + 164FE9EB1F02F340009419CA /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9EA1F02F340009419CA /* Helpers.swift */; }; + 164FE9ED1F02F7E2009419CA /* UIButtonExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9EC1F02F7E2009419CA /* UIButtonExtension.swift */; }; + 164FE9EF1F03065C009419CA /* NavigationControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */; }; + 165484551F902D3F005AEA23 /* GeneralCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */; }; + 166036211F266FF300A22B7B /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 166036201F266FF300A22B7B /* Notifications.swift */; }; + 1671BD56200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */; }; + 1671BD58200D361900E6ED3B /* SubscriptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */; }; + 1671BD5A200D3D7800E6ED3B /* SubscriptionStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */; }; + 167AFAB71F043F1100A1332F /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 167AFAB61F043F1100A1332F /* HeaderView.swift */; }; + 1686FC011F009EC00088A6C1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1686FC001F009EC00088A6C1 /* AppDelegate.swift */; }; + 1686FC061F009EC00088A6C1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC041F009EC00088A6C1 /* Main.storyboard */; }; + 1686FC081F009EC00088A6C1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC071F009EC00088A6C1 /* Assets.xcassets */; }; + 1686FC0B1F009EC00088A6C1 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1686FC091F009EC00088A6C1 /* LaunchScreen.storyboard */; }; + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */; }; + 1688CB4C2006BDDA00440095 /* APIStripeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */; }; + 169806A61F8EF3970075D8AD /* L10nEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 169806A51F8EF08F0075D8AD /* L10nEnum.swift */; }; + 16AD3E0A1F9B138D0084C545 /* PodcastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E091F9B138D0084C545 /* PodcastViewModel.swift */; }; + 16AD3E0C1F9B13EE0084C545 /* FilterObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */; }; + 16AD3E0E1F9B14130084C545 /* PodcastDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */; }; + 16B147B11F16BF9C00433A42 /* AudioOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16B147B01F16BF9C00433A42 /* AudioOverlayViewController.swift */; }; + 16CE698F1F98029E0057BAC3 /* PodcastDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */; }; + 16D67C4A1F33AC620065E838 /* AnswersTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D67C491F33AC620065E838 /* AnswersTracker.swift */; }; + 16D766BA1F06B4850066C143 /* AudioView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16D766B91F06B4850066C143 /* AudioView.swift */; }; + 16F3A1BA1F90918D00364709 /* PodcastRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F3A1B91F90918D00364709 /* PodcastRepository.swift */; }; + 16FA84031F8D323700A45D9B /* SkeletonCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */; }; + 1E286DE31FE4C5BA00644C1D /* TestHookEventTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */; }; + 1E286DE51FE4C63200644C1D /* TestHookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE41FE4C63200644C1D /* TestHookManager.swift */; }; + 1E286DE91FE4C65200644C1D /* TestHookEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */; }; + 1E2BFDCD2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */; }; + 1E44AF031F87B08D00221B22 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1E44AF051F87B08D00221B22 /* Localizable.strings */; }; + 1E638E561FC794AC00A29BDC /* ProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */; }; + 1E706BD21FD620B100D44AB2 /* BookmarkCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */; }; + 1E706BD41FD62E0300D44AB2 /* BookmarkViewModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */; }; + 1EABD8F41FB430AB00959859 /* Debug.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1EABD8F11FB430AA00959859 /* Debug.storyboard */; }; + 1EABD8F51FB430AB00959859 /* DebugTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */; }; + 1EABD8F61FB430AB00959859 /* TestHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EABD8F31FB430AA00959859 /* TestHook.swift */; }; + 1EB74FFC1FE5E1AF004B733E /* StateBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */; }; + 238E99F4E37C7365658113B8 /* Pods_SEDaily_IOSTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF0C9F051C6C2F8638D2ABF8 /* Pods_SEDaily_IOSTests.framework */; }; + 24412438C753126FDDD37CBF /* Pods_SEDaily_IOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A3651E5B02B83E392546E167 /* Pods_SEDaily_IOS.framework */; }; +<<<<<<< HEAD +======= + 43CE140C225F7EF700B57CFA /* PlayProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE140B225F7EF700B57CFA /* PlayProgress.swift */; }; + 43CE140E226070FB00B57CFA /* PlayProgressModelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */; }; +>>>>>>> play_progress_refactor + 96004ACE20A9E0030017230F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 96004ACD20A9E0030017230F /* GoogleService-Info.plist */; }; + 960BFEBB202109800073DAB2 /* AskForReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEBA202109800073DAB2 /* AskForReview.swift */; }; + 960BFEBF20226B620073DAB2 /* CommentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */; }; + 960BFEC120226F1D0073DAB2 /* Comments.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 960BFEC020226F1D0073DAB2 /* Comments.storyboard */; }; + 960BFEC320239F0E0073DAB2 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC220239F0E0073DAB2 /* Comment.swift */; }; + 960BFEC52023C1330073DAB2 /* CommentsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */; }; + 960BFEC7202539180073DAB2 /* CommentReplyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC6202539180073DAB2 /* CommentReplyTableViewCell.swift */; }; + 960BFEC9202539640073DAB2 /* CommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 960BFEC8202539640073DAB2 /* CommentTableViewCell.swift */; }; + 9610313E20A353B400A2D2D5 /* AnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */; }; + 961A378D208FAFEF0050EF80 /* ForumThreadLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961A378C208FAFEF0050EF80 /* ForumThreadLite.swift */; }; + 962F5365201E3C7900897A6E /* RelatedLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */; }; + 963144DE208FEA8000EA13F1 /* ForumThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963144DD208FEA8000EA13F1 /* ForumThread.swift */; }; + 964090BF2093B97D00CFF5C4 /* PodcastLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */; }; + 965798A52093FDE600104F8F /* FeedItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 965798A42093FDE600104F8F /* FeedItemCell.swift */; }; + 965E165820ABD74900F2E4E4 /* FeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 965E165720ABD74900F2E4E4 /* FeedItem.swift */; }; + 96600312201CEBBD00997795 /* RelatedLinks.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 96600311201CEBBD00997795 /* RelatedLinks.storyboard */; }; + 9662FC0A20AF21FA00CFA8DF /* BaseFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */; }; + 9672AA52202929C90020981F /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9672AA51202929C90020981F /* Author.swift */; }; + 96A7E2CF20951EBD002C92EC /* ThreadHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A7E2CE20951EBD002C92EC /* ThreadHeaderView.swift */; }; + 96B6168820C0883C00DF3CF6 /* ForumListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B6168620C0883C00DF3CF6 /* ForumListViewController.swift */; }; + 96B6168B20C0A59500DF3CF6 /* ForumThreadCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96B6168A20C0A59500DF3CF6 /* ForumThreadCell.swift */; }; + 96BD1C88208E9497006C5E1D /* TestHookBool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96BD1C87208E9497006C5E1D /* TestHookBool.swift */; }; + 96C8A9AB2091038100489EE4 /* FeedListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C8A9AA2091038100489EE4 /* FeedListViewController.swift */; }; + 96C8A9AF209114C200489EE4 /* FeedList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 96C8A9AE209114C200489EE4 /* FeedList.storyboard */; }; + 96D268F720B2250B002F95BD /* RelatedLinkWebVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96D268F520B2250B002F95BD /* RelatedLinkWebVC.swift */; }; + 96D268F820B2250B002F95BD /* RelatedLinkWebVC.xib in Resources */ = {isa = PBXBuildFile; fileRef = 96D268F620B2250B002F95BD /* RelatedLinkWebVC.xib */; }; + 96F35745201BFB0200E8B6E9 /* RelatedLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */; }; + 96F4016F20C075E600D6A66A /* ForumList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 96F4016E20C075E600D6A66A /* ForumList.storyboard */; }; + C0109D992013ECE0008BDA69 /* upvote_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C0109D982013ECE0008BDA69 /* upvote_success.json */; }; + C0109D9B2013F064008BDA69 /* upvote_failure.json in Resources */ = {isa = PBXBuildFile; fileRef = C0109D9A2013F064008BDA69 /* upvote_failure.json */; }; + C0109D9E2013F629008BDA69 /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109D9D2013F629008BDA69 /* LoginTests.swift */; }; + C0109DA02013F6A5008BDA69 /* RegisterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */; }; + C0109DA22013F6D8008BDA69 /* PostsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0109DA12013F6D8008BDA69 /* PostsTests.swift */; }; + C015CB17201132DE00C6FD82 /* register_userexists.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB16201132DE00C6FD82 /* register_userexists.json */; }; + C015CB192011373000C6FD82 /* register_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB182011373000C6FD82 /* register_success.json */; }; + C015CB1B2011388C00C6FD82 /* register_emptyusernamepass.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */; }; + C015CB1E20113CAB00C6FD82 /* getPostsWith_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */; }; + C04C37FD200E94AE00C6EFC6 /* login_success.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C37FC200E94AE00C6EFC6 /* login_success.json */; }; + C04C3800200EA6F400C6EFC6 /* login_wrongpass.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */; }; + C04C3802200EA99200C6EFC6 /* login_nonexistinguser.json in Resources */ = {isa = PBXBuildFile; fileRef = C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */; }; + C0590D78200AC2A500A00E4D /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0590D77200AC2A500A00E4D /* NetworkService.swift */; }; + C0590D7A200AD36500A00E4D /* VotingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0590D79200AD36400A00E4D /* VotingTests.swift */; }; + C05DA1D92013FA34003C631F /* getPosts_topPosts.json in Resources */ = {isa = PBXBuildFile; fileRef = C05DA1D82013FA34003C631F /* getPosts_topPosts.json */; }; + C079B6541FCA07F800B4B304 /* HelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C079B6531FCA07F800B4B304 /* HelpersTests.swift */; }; + C0E64BB41FCB3F9100753AF0 /* UserModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */; }; + C0E64BB71FCB406000753AF0 /* UserDefaultsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 1686FC121F009EC00088A6C1 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 1686FBF51F009EC00088A6C1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1686FBFC1F009EC00088A6C1; + remoteInfo = "SEDaily-IOS"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 01516A03207310C900E9E743 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; + 01707420207192C0002E6E3C /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = ""; }; + 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastPageViewController.swift; sourceTree = ""; }; + 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineDownloadsManager.swift; sourceTree = ""; }; + 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchTableViewController.swift; sourceTree = ""; }; + 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastTableViewCell.swift; sourceTree = ""; }; + 162FFD3F1FBE200E0026288D /* DiskKeys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskKeys.swift; sourceTree = ""; }; + 16307FC91F8FDD02001783CB /* Podcast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Podcast.swift; sourceTree = ""; }; + 16307FCB1F8FEE3A001783CB /* PodcastViewModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastViewModelController.swift; sourceTree = ""; }; + 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContainerViewController.swift; sourceTree = ""; }; + 164C71041F021AC8003803BC /* CustomTabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomTabViewController.swift; sourceTree = ""; }; + 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PodcastCollectionViewCell.swift; sourceTree = ""; }; + 164FE9E01F02EE67009419CA /* LoginViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; + 164FE9E41F02EE83009419CA /* API.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; + 164FE9E61F02F02F009419CA /* UserModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserModel.swift; sourceTree = ""; }; + 164FE9E81F02F049009419CA /* Stylesheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Stylesheet.swift; sourceTree = ""; }; + 164FE9EA1F02F340009419CA /* Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; + 164FE9EC1F02F7E2009419CA /* UIButtonExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIButtonExtension.swift; sourceTree = ""; }; + 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerExtension.swift; sourceTree = ""; }; + 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCollectionViewController.swift; sourceTree = ""; }; + 166036201F266FF300A22B7B /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; + 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseSubscriptionViewController.swift; sourceTree = ""; }; + 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionModel.swift; sourceTree = ""; }; + 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusViewController.swift; sourceTree = ""; }; + 167AFAB61F043F1100A1332F /* HeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; + 1686FBFD1F009EC00088A6C1 /* SEDaily-IOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "SEDaily-IOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 1686FC001F009EC00088A6C1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 1686FC051F009EC00088A6C1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 1686FC071F009EC00088A6C1 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 1686FC0A1F009EC00088A6C1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 1686FC0C1F009EC00088A6C1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1686FC111F009EC00088A6C1 /* SEDaily-IOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SEDaily-IOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEDailyIOSTests.swift; sourceTree = ""; }; + 1686FC171F009EC00088A6C1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIStripeExtension.swift; sourceTree = ""; }; + 169806A51F8EF08F0075D8AD /* L10nEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = L10nEnum.swift; sourceTree = ""; }; + 16AD3E091F9B138D0084C545 /* PodcastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastViewModel.swift; sourceTree = ""; }; + 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterObject.swift; sourceTree = ""; }; + 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastDataSource.swift; sourceTree = ""; }; + 16B147B01F16BF9C00433A42 /* AudioOverlayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioOverlayViewController.swift; sourceTree = ""; }; + 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastDetailViewController.swift; sourceTree = ""; }; + 16D67C491F33AC620065E838 /* AnswersTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnswersTracker.swift; sourceTree = ""; }; + 16D766B91F06B4850066C143 /* AudioView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioView.swift; sourceTree = ""; }; + 16F3A1B91F90918D00364709 /* PodcastRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRepository.swift; sourceTree = ""; }; + 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCollectionView.swift; sourceTree = ""; }; + 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookEventTableViewCell.swift; sourceTree = ""; }; + 1E286DE41FE4C63200644C1D /* TestHookManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookManager.swift; sourceTree = ""; }; + 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHookEvent.swift; sourceTree = ""; }; + 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHookBoolTableViewCell.swift; sourceTree = ""; }; + 1E44AEFF1F87ACF500221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/LaunchScreen.strings; sourceTree = ""; }; + 1E44AF001F87ACF500221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; + 1E44AF061F87B09100221B22 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; + 1E44AF081F87B0A700221B22 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressIndicator.swift; sourceTree = ""; }; + 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BookmarkCollectionViewController.swift; path = Bookmark/BookmarkCollectionViewController.swift; sourceTree = ""; }; + 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BookmarkViewModelController.swift; path = Bookmark/BookmarkViewModelController.swift; sourceTree = ""; }; + 1EABD8F11FB430AA00959859 /* Debug.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Debug.storyboard; sourceTree = ""; }; + 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugTabViewController.swift; sourceTree = ""; }; + 1EABD8F31FB430AA00959859 /* TestHook.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHook.swift; sourceTree = ""; }; + 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StateBookmarkView.swift; path = Bookmark/StateBookmarkView.swift; sourceTree = ""; }; +<<<<<<< HEAD +======= + 43CE140B225F7EF700B57CFA /* PlayProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayProgress.swift; sourceTree = ""; }; + 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayProgressModelController.swift; sourceTree = ""; }; +>>>>>>> play_progress_refactor + 675FBEB71FD81FBA8767227A /* Pods-SEDaily-IOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOS.release.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS.release.xcconfig"; sourceTree = ""; }; + 96004ACD20A9E0030017230F /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 960BFEBA202109800073DAB2 /* AskForReview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AskForReview.swift; sourceTree = ""; }; + 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsViewController.swift; sourceTree = ""; }; + 960BFEC020226F1D0073DAB2 /* Comments.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Comments.storyboard; sourceTree = ""; }; + 960BFEC220239F0E0073DAB2 /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsResponse.swift; sourceTree = ""; }; + 960BFEC6202539180073DAB2 /* CommentReplyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentReplyTableViewCell.swift; sourceTree = ""; }; + 960BFEC8202539640073DAB2 /* CommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentTableViewCell.swift; sourceTree = ""; }; + 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHelper.swift; sourceTree = ""; }; + 961A378C208FAFEF0050EF80 /* ForumThreadLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumThreadLite.swift; sourceTree = ""; }; + 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinksViewController.swift; sourceTree = ""; }; + 963144DD208FEA8000EA13F1 /* ForumThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumThread.swift; sourceTree = ""; }; + 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLite.swift; sourceTree = ""; }; + 965798A42093FDE600104F8F /* FeedItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCell.swift; sourceTree = ""; }; + 965E165720ABD74900F2E4E4 /* FeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItem.swift; sourceTree = ""; }; + 96600311201CEBBD00997795 /* RelatedLinks.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = RelatedLinks.storyboard; sourceTree = ""; }; + 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseFeedItem.swift; sourceTree = ""; }; + 9672AA51202929C90020981F /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = ""; }; + 96A7E2CE20951EBD002C92EC /* ThreadHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadHeaderView.swift; sourceTree = ""; }; + 96B6168620C0883C00DF3CF6 /* ForumListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumListViewController.swift; sourceTree = ""; }; + 96B6168A20C0A59500DF3CF6 /* ForumThreadCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumThreadCell.swift; sourceTree = ""; }; + 96BD1C87208E9497006C5E1D /* TestHookBool.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHookBool.swift; sourceTree = ""; }; + 96C8A9AA2091038100489EE4 /* FeedListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedListViewController.swift; sourceTree = ""; }; + 96C8A9AE209114C200489EE4 /* FeedList.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = FeedList.storyboard; sourceTree = ""; }; + 96D268F520B2250B002F95BD /* RelatedLinkWebVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLinkWebVC.swift; sourceTree = ""; }; + 96D268F620B2250B002F95BD /* RelatedLinkWebVC.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RelatedLinkWebVC.xib; sourceTree = ""; }; + 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedLink.swift; sourceTree = ""; }; + 96F4016E20C075E600D6A66A /* ForumList.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ForumList.storyboard; sourceTree = ""; }; + 995F64385352A79BA0DF7DEB /* Pods-SEDaily-IOSTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOSTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests.debug.xcconfig"; sourceTree = ""; }; + 9FDA28C254F62BB468401B75 /* Pods-SEDaily-IOS.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOS.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS.debug.xcconfig"; sourceTree = ""; }; + A2EFA433B346D70E958FFC73 /* Pods-SEDaily-IOSTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SEDaily-IOSTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests.release.xcconfig"; sourceTree = ""; }; + A3651E5B02B83E392546E167 /* Pods_SEDaily_IOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SEDaily_IOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C0109D982013ECE0008BDA69 /* upvote_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = upvote_success.json; sourceTree = ""; }; + C0109D9A2013F064008BDA69 /* upvote_failure.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = upvote_failure.json; sourceTree = ""; }; + C0109D9D2013F629008BDA69 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = ""; }; + C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterTests.swift; sourceTree = ""; }; + C0109DA12013F6D8008BDA69 /* PostsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsTests.swift; sourceTree = ""; }; + C015CB16201132DE00C6FD82 /* register_userexists.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_userexists.json; sourceTree = ""; }; + C015CB182011373000C6FD82 /* register_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_success.json; sourceTree = ""; }; + C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = register_emptyusernamepass.json; sourceTree = ""; }; + C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = getPostsWith_success.json; sourceTree = ""; }; + C04C37FC200E94AE00C6EFC6 /* login_success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_success.json; sourceTree = ""; }; + C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_wrongpass.json; sourceTree = ""; }; + C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = login_nonexistinguser.json; sourceTree = ""; }; + C0590D77200AC2A500A00E4D /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = ""; }; + C0590D79200AD36400A00E4D /* VotingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VotingTests.swift; sourceTree = ""; }; + C05DA1D82013FA34003C631F /* getPosts_topPosts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = getPosts_topPosts.json; sourceTree = ""; }; + C079B6531FCA07F800B4B304 /* HelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpersTests.swift; sourceTree = ""; }; + C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModelTests.swift; sourceTree = ""; }; + C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsMock.swift; sourceTree = ""; }; + DF0C9F051C6C2F8638D2ABF8 /* Pods_SEDaily_IOSTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SEDaily_IOSTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1686FBFA1F009EC00088A6C1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 24412438C753126FDDD37CBF /* Pods_SEDaily_IOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1686FC0E1F009EC00088A6C1 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 238E99F4E37C7365658113B8 /* Pods_SEDaily_IOSTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 017074222071938D002E6E3C /* Notifications */ = { + isa = PBXGroup; + children = ( + 01707420207192C0002E6E3C /* NotificationsTableViewController.swift */, + 01516A03207310C900E9E743 /* NotificationTableViewCell.swift */, + ); + name = Notifications; + sourceTree = ""; + }; + 161791FA1FC4DA5800A1287E /* Offline Downloads */ = { + isa = PBXGroup; + children = ( + 161791FB1FC4DA7200A1287E /* OfflineDownloadsManager.swift */, + ); + name = "Offline Downloads"; + sourceTree = ""; + }; + 16307FC81F8FDCE4001783CB /* PodcastModels */ = { + isa = PBXGroup; + children = ( + 16AD3E0B1F9B13EE0084C545 /* FilterObject.swift */, + 16307FC91F8FDD02001783CB /* Podcast.swift */, + 16AD3E0D1F9B14130084C545 /* PodcastDataSource.swift */, + 16F3A1B91F90918D00364709 /* PodcastRepository.swift */, + 16AD3E091F9B138D0084C545 /* PodcastViewModel.swift */, + 16307FCB1F8FEE3A001783CB /* PodcastViewModelController.swift */, + ); + name = PodcastModels; + sourceTree = ""; + }; + 164FE9DF1F02EE4E009419CA /* Podcasts */ = { + isa = PBXGroup; + children = ( + 165484541F902D3F005AEA23 /* GeneralCollectionViewController.swift */, + 16FA84021F8D323700A45D9B /* SkeletonCollectionView.swift */, + 16AD3E001F9A975F0084C545 /* PodcastDetail */, + 16307FC81F8FDCE4001783CB /* PodcastModels */, + ); + name = Podcasts; + sourceTree = ""; + }; + 164FE9E21F02EE71009419CA /* CommonModels */ = { + isa = PBXGroup; + children = ( + 164FE9E61F02F02F009419CA /* UserModel.swift */, + ); + name = CommonModels; + sourceTree = ""; + }; + 164FE9E31F02EE77009419CA /* Helpers */ = { + isa = PBXGroup; + children = ( + 162FFD3F1FBE200E0026288D /* DiskKeys.swift */, + 164FE9E81F02F049009419CA /* Stylesheet.swift */, + 164FE9EA1F02F340009419CA /* Helpers.swift */, + 164FE9EC1F02F7E2009419CA /* UIButtonExtension.swift */, + 164FE9EE1F03065C009419CA /* NavigationControllerExtension.swift */, + 166036201F266FF300A22B7B /* Notifications.swift */, + 16D67C491F33AC620065E838 /* AnswersTracker.swift */, + 1E638E551FC794AC00A29BDC /* ProgressIndicator.swift */, + 960BFEBA202109800073DAB2 /* AskForReview.swift */, + 9610313D20A353B400A2D2D5 /* AnalyticsHelper.swift */, + ); + name = Helpers; + sourceTree = ""; + }; + 1686FBF41F009EC00088A6C1 = { + isa = PBXGroup; + children = ( + 1686FBFF1F009EC00088A6C1 /* SEDaily-IOS */, + 1686FC141F009EC00088A6C1 /* SEDaily-IOSTests */, + 1686FBFE1F009EC00088A6C1 /* Products */, + 4E74333675FE5899EC260076 /* Pods */, + E6ABCD02C9CBA1E6E09EB4DE /* Frameworks */, + ); + sourceTree = ""; + }; + 1686FBFE1F009EC00088A6C1 /* Products */ = { + isa = PBXGroup; + children = ( + 1686FBFD1F009EC00088A6C1 /* SEDaily-IOS.app */, + 1686FC111F009EC00088A6C1 /* SEDaily-IOSTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 1686FBFF1F009EC00088A6C1 /* SEDaily-IOS */ = { + isa = PBXGroup; + children = ( + 96B6168520C0861300DF3CF6 /* Forum */, + 961A378B208FAFC30050EF80 /* Feed */, + 961A378A208FAF3E0050EF80 /* User */, + 960BFEBD20226B420073DAB2 /* Comments */, + 96600306201CE9F900997795 /* RelatedLinks */, + 017074222071938D002E6E3C /* Notifications */, + 161791FA1FC4DA5800A1287E /* Offline Downloads */, + 1688CB4A2006BDCE00440095 /* Stripe */, + 164FE9E41F02EE83009419CA /* API.swift */, + 1686FC001F009EC00088A6C1 /* AppDelegate.swift */, + 1686FC071F009EC00088A6C1 /* Assets.xcassets */, + 16AD3E011F9B06480084C545 /* Audio */, + 16AD3E071F9B07520084C545 /* Auth */, + 1E2BFDCE2017D91200E6DE0A /* Bookmark */, + 16AD3E051F9B07160084C545 /* CommonCells */, + 164FE9E21F02EE71009419CA /* CommonModels */, + 16AD3E061F9B07320084C545 /* CommonVCs */, + 169806A41F8EF08F0075D8AD /* Constants */, + 16AD3E081F9B07A70084C545 /* Core */, + 1EABD8F01FB430AA00959859 /* Debug */, + 164FE9E31F02EE77009419CA /* Helpers */, + 1686FC0C1F009EC00088A6C1 /* Info.plist */, + 96004ACD20A9E0030017230F /* GoogleService-Info.plist */, + 1E44AF051F87B08D00221B22 /* Localizable.strings */, + 164FE9DF1F02EE4E009419CA /* Podcasts */, + 16AD3E031F9B06950084C545 /* Search */, + C0590D77200AC2A500A00E4D /* NetworkService.swift */, + ); + path = "SEDaily-IOS"; + sourceTree = ""; + }; + 1686FC141F009EC00088A6C1 /* SEDaily-IOSTests */ = { + isa = PBXGroup; + children = ( + C0109D9C2013F538008BDA69 /* APITests */, + C0E64BB51FCB404B00753AF0 /* Mocks */, + 1686FC151F009EC00088A6C1 /* SEDailyIOSTests.swift */, + 1686FC171F009EC00088A6C1 /* Info.plist */, + C079B6531FCA07F800B4B304 /* HelpersTests.swift */, + C0E64BB31FCB3F9100753AF0 /* UserModelTests.swift */, + ); + path = "SEDaily-IOSTests"; + sourceTree = ""; + }; + 1688CB4A2006BDCE00440095 /* Stripe */ = { + isa = PBXGroup; + children = ( + 1671BD57200D361900E6ED3B /* SubscriptionModel.swift */, + 1688CB4B2006BDDA00440095 /* APIStripeExtension.swift */, + 1671BD55200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift */, + 1671BD59200D3D7800E6ED3B /* SubscriptionStatusViewController.swift */, + ); + name = Stripe; + sourceTree = ""; + }; + 169806A41F8EF08F0075D8AD /* Constants */ = { + isa = PBXGroup; + children = ( + 169806A51F8EF08F0075D8AD /* L10nEnum.swift */, + ); + path = Constants; + sourceTree = SOURCE_ROOT; + }; + 16AD3E001F9A975F0084C545 /* PodcastDetail */ = { + isa = PBXGroup; + children = ( + 167AFAB61F043F1100A1332F /* HeaderView.swift */, + 16CE698E1F98029E0057BAC3 /* PodcastDetailViewController.swift */, + ); + name = PodcastDetail; + sourceTree = ""; + }; + 16AD3E011F9B06480084C545 /* Audio */ = { + isa = PBXGroup; + children = ( + 16B147B01F16BF9C00433A42 /* AudioOverlayViewController.swift */, + 16D766B91F06B4850066C143 /* AudioView.swift */, +<<<<<<< HEAD +======= + 43CE140B225F7EF700B57CFA /* PlayProgress.swift */, + 43CE140D226070FB00B57CFA /* PlayProgressModelController.swift */, +>>>>>>> play_progress_refactor + ); + name = Audio; + sourceTree = ""; + }; + 16AD3E031F9B06950084C545 /* Search */ = { + isa = PBXGroup; + children = ( + 161F3DE51F61F73D00A8F825 /* SearchTableViewController.swift */, + ); + name = Search; + sourceTree = ""; + }; + 16AD3E051F9B07160084C545 /* CommonCells */ = { + isa = PBXGroup; + children = ( + 161F3DE91F62703100A8F825 /* PodcastTableViewCell.swift */, + 164FE9DB1F02DAB5009419CA /* PodcastCollectionViewCell.swift */, + ); + name = CommonCells; + sourceTree = ""; + }; + 16AD3E061F9B07320084C545 /* CommonVCs */ = { + isa = PBXGroup; + children = ( + 164C71041F021AC8003803BC /* CustomTabViewController.swift */, + 01BB1D6C1F29999E004A912E /* PodcastPageViewController.swift */, + 1649A7891F204EC6005C4A6E /* ContainerViewController.swift */, + ); + name = CommonVCs; + sourceTree = ""; + }; + 16AD3E071F9B07520084C545 /* Auth */ = { + isa = PBXGroup; + children = ( + 164FE9E01F02EE67009419CA /* LoginViewController.swift */, + ); + name = Auth; + sourceTree = ""; + }; + 16AD3E081F9B07A70084C545 /* Core */ = { + isa = PBXGroup; + children = ( + 1686FC091F009EC00088A6C1 /* LaunchScreen.storyboard */, + 1686FC041F009EC00088A6C1 /* Main.storyboard */, + ); + name = Core; + sourceTree = ""; + }; + 1E2BFDCE2017D91200E6DE0A /* Bookmark */ = { + isa = PBXGroup; + children = ( + 1E706BD11FD620B100D44AB2 /* BookmarkCollectionViewController.swift */, + 1EB74FFB1FE5E1AF004B733E /* StateBookmarkView.swift */, + 1E706BD31FD62E0300D44AB2 /* BookmarkViewModelController.swift */, + ); + name = Bookmark; + sourceTree = ""; + }; + 1EABD8F01FB430AA00959859 /* Debug */ = { + isa = PBXGroup; + children = ( + 1EABD8F11FB430AA00959859 /* Debug.storyboard */, + 96BD1C87208E9497006C5E1D /* TestHookBool.swift */, + 1EABD8F21FB430AA00959859 /* DebugTabViewController.swift */, + 1E2BFDCC2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift */, + 1EABD8F31FB430AA00959859 /* TestHook.swift */, + 1E286DE81FE4C65200644C1D /* TestHookEvent.swift */, + 1E286DE21FE4C5BA00644C1D /* TestHookEventTableViewCell.swift */, + 1E286DE41FE4C63200644C1D /* TestHookManager.swift */, + ); + path = Debug; + sourceTree = ""; + }; + 4E74333675FE5899EC260076 /* Pods */ = { + isa = PBXGroup; + children = ( + 9FDA28C254F62BB468401B75 /* Pods-SEDaily-IOS.debug.xcconfig */, + 675FBEB71FD81FBA8767227A /* Pods-SEDaily-IOS.release.xcconfig */, + 995F64385352A79BA0DF7DEB /* Pods-SEDaily-IOSTests.debug.xcconfig */, + A2EFA433B346D70E958FFC73 /* Pods-SEDaily-IOSTests.release.xcconfig */, + ); + name = Pods; + sourceTree = ""; + }; + 960BFEBD20226B420073DAB2 /* Comments */ = { + isa = PBXGroup; + children = ( + 960BFEBE20226B620073DAB2 /* CommentsViewController.swift */, + 960BFEC42023C1330073DAB2 /* CommentsResponse.swift */, + 960BFEC020226F1D0073DAB2 /* Comments.storyboard */, + 960BFEC220239F0E0073DAB2 /* Comment.swift */, + 960BFEC6202539180073DAB2 /* CommentReplyTableViewCell.swift */, + 960BFEC8202539640073DAB2 /* CommentTableViewCell.swift */, + ); + name = Comments; + sourceTree = ""; + }; + 961A378A208FAF3E0050EF80 /* User */ = { + isa = PBXGroup; + children = ( + 9672AA51202929C90020981F /* Author.swift */, + ); + name = User; + sourceTree = ""; + }; + 961A378B208FAFC30050EF80 /* Feed */ = { + isa = PBXGroup; + children = ( + 96C8A9AE209114C200489EE4 /* FeedList.storyboard */, + 96C8A9AA2091038100489EE4 /* FeedListViewController.swift */, + 964090BE2093B97D00CFF5C4 /* PodcastLite.swift */, + 965798A42093FDE600104F8F /* FeedItemCell.swift */, + 965E165720ABD74900F2E4E4 /* FeedItem.swift */, + 9662FC0920AF21FA00CFA8DF /* BaseFeedItem.swift */, + 96D268F520B2250B002F95BD /* RelatedLinkWebVC.swift */, + 96D268F620B2250B002F95BD /* RelatedLinkWebVC.xib */, + ); + name = Feed; + sourceTree = ""; + }; + 96600306201CE9F900997795 /* RelatedLinks */ = { + isa = PBXGroup; + children = ( + 96F35744201BFB0200E8B6E9 /* RelatedLink.swift */, + 96600311201CEBBD00997795 /* RelatedLinks.storyboard */, + 962F5364201E3C7900897A6E /* RelatedLinksViewController.swift */, + ); + name = RelatedLinks; + sourceTree = ""; + }; + 96B6168520C0861300DF3CF6 /* Forum */ = { + isa = PBXGroup; + children = ( + 961A378C208FAFEF0050EF80 /* ForumThreadLite.swift */, + 96A7E2CE20951EBD002C92EC /* ThreadHeaderView.swift */, + 963144DD208FEA8000EA13F1 /* ForumThread.swift */, + 96F4016E20C075E600D6A66A /* ForumList.storyboard */, + 96B6168620C0883C00DF3CF6 /* ForumListViewController.swift */, + 96B6168A20C0A59500DF3CF6 /* ForumThreadCell.swift */, + ); + name = Forum; + sourceTree = ""; + }; + C0109D9C2013F538008BDA69 /* APITests */ = { + isa = PBXGroup; + children = ( + C0590D79200AD36400A00E4D /* VotingTests.swift */, + C0109D9D2013F629008BDA69 /* LoginTests.swift */, + C0109D9F2013F6A5008BDA69 /* RegisterTests.swift */, + C0109DA12013F6D8008BDA69 /* PostsTests.swift */, + ); + path = APITests; + sourceTree = ""; + }; + C015CB15201132C600C6FD82 /* register */ = { + isa = PBXGroup; + children = ( + C015CB16201132DE00C6FD82 /* register_userexists.json */, + C015CB182011373000C6FD82 /* register_success.json */, + C015CB1A2011388C00C6FD82 /* register_emptyusernamepass.json */, + ); + path = register; + sourceTree = ""; + }; + C015CB1C20113C9800C6FD82 /* posts */ = { + isa = PBXGroup; + children = ( + C015CB1D20113CAB00C6FD82 /* getPostsWith_success.json */, + C0109D982013ECE0008BDA69 /* upvote_success.json */, + C0109D9A2013F064008BDA69 /* upvote_failure.json */, + C05DA1D82013FA34003C631F /* getPosts_topPosts.json */, + ); + path = posts; + sourceTree = ""; + }; + C04C37FB200E948F00C6EFC6 /* Responses */ = { + isa = PBXGroup; + children = ( + C015CB1C20113C9800C6FD82 /* posts */, + C015CB15201132C600C6FD82 /* register */, + C04C37FE200EA5D000C6EFC6 /* login */, + ); + path = Responses; + sourceTree = ""; + }; + C04C37FE200EA5D000C6EFC6 /* login */ = { + isa = PBXGroup; + children = ( + C04C37FC200E94AE00C6EFC6 /* login_success.json */, + C04C37FF200EA6F400C6EFC6 /* login_wrongpass.json */, + C04C3801200EA99200C6EFC6 /* login_nonexistinguser.json */, + ); + path = login; + sourceTree = ""; + }; + C0E64BB51FCB404B00753AF0 /* Mocks */ = { + isa = PBXGroup; + children = ( + C04C37FB200E948F00C6EFC6 /* Responses */, + C0E64BB61FCB406000753AF0 /* UserDefaultsMock.swift */, + ); + path = Mocks; + sourceTree = ""; + }; + E6ABCD02C9CBA1E6E09EB4DE /* Frameworks */ = { + isa = PBXGroup; + children = ( + A3651E5B02B83E392546E167 /* Pods_SEDaily_IOS.framework */, + DF0C9F051C6C2F8638D2ABF8 /* Pods_SEDaily_IOSTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1686FBFC1F009EC00088A6C1 /* SEDaily-IOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1686FC1A1F009EC00088A6C1 /* Build configuration list for PBXNativeTarget "SEDaily-IOS" */; + buildPhases = ( + 6A12E8C003C75BEEE01B2AA0 /* [CP] Check Pods Manifest.lock */, + 169806A21F8EED850075D8AD /* Swiftgen Localize Strings Script */, + 1686FBF91F009EC00088A6C1 /* Sources */, + 1686FBFA1F009EC00088A6C1 /* Frameworks */, + 1686FBFB1F009EC00088A6C1 /* Resources */, + AE5F616C12D0339AD2CE40F0 /* [CP] Embed Pods Frameworks */, + 16D67C4B1F33AD1D0065E838 /* ShellScript */, + 1E884C331FA428D400400781 /* SwiftLint */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "SEDaily-IOS"; + productName = "SEDaily-IOS"; + productReference = 1686FBFD1F009EC00088A6C1 /* SEDaily-IOS.app */; + productType = "com.apple.product-type.application"; + }; + 1686FC101F009EC00088A6C1 /* SEDaily-IOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1686FC1D1F009EC00088A6C1 /* Build configuration list for PBXNativeTarget "SEDaily-IOSTests" */; + buildPhases = ( + 0AC6AC0CAA2207AE56408B2B /* [CP] Check Pods Manifest.lock */, + 1686FC0D1F009EC00088A6C1 /* Sources */, + 1686FC0E1F009EC00088A6C1 /* Frameworks */, + 1686FC0F1F009EC00088A6C1 /* Resources */, + 43BA1A9ED42DE22C48E64092 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 1686FC131F009EC00088A6C1 /* PBXTargetDependency */, + ); + name = "SEDaily-IOSTests"; + productName = "SEDaily-IOSTests"; + productReference = 1686FC111F009EC00088A6C1 /* SEDaily-IOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1686FBF51F009EC00088A6C1 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0830; + LastUpgradeCheck = 0900; + ORGANIZATIONNAME = "Koala Tea"; + TargetAttributes = { + 1686FBFC1F009EC00088A6C1 = { + CreatedOnToolsVersion = 8.3.2; + LastSwiftMigration = 0900; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; + }; + }; + 1686FC101F009EC00088A6C1 = { + CreatedOnToolsVersion = 8.3.2; + DevelopmentTeam = 6MS9QWALYV; + LastSwiftMigration = 0900; + ProvisioningStyle = Automatic; + TestTargetID = 1686FBFC1F009EC00088A6C1; + }; + }; + }; + buildConfigurationList = 1686FBF81F009EC00088A6C1 /* Build configuration list for PBXProject "SEDaily-IOS" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + English, + en, + Base, + fr, + ); + mainGroup = 1686FBF41F009EC00088A6C1; + productRefGroup = 1686FBFE1F009EC00088A6C1 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1686FBFC1F009EC00088A6C1 /* SEDaily-IOS */, + 1686FC101F009EC00088A6C1 /* SEDaily-IOSTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1686FBFB1F009EC00088A6C1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 96F4016F20C075E600D6A66A /* ForumList.storyboard in Resources */, + 96C8A9AF209114C200489EE4 /* FeedList.storyboard in Resources */, + 96600312201CEBBD00997795 /* RelatedLinks.storyboard in Resources */, + 96004ACE20A9E0030017230F /* GoogleService-Info.plist in Resources */, + 96D268F820B2250B002F95BD /* RelatedLinkWebVC.xib in Resources */, + 1686FC0B1F009EC00088A6C1 /* LaunchScreen.storyboard in Resources */, + 1EABD8F41FB430AB00959859 /* Debug.storyboard in Resources */, + 1E44AF031F87B08D00221B22 /* Localizable.strings in Resources */, + 960BFEC120226F1D0073DAB2 /* Comments.storyboard in Resources */, + 1686FC081F009EC00088A6C1 /* Assets.xcassets in Resources */, + 1686FC061F009EC00088A6C1 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1686FC0F1F009EC00088A6C1 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + C0109D9B2013F064008BDA69 /* upvote_failure.json in Resources */, + C015CB192011373000C6FD82 /* register_success.json in Resources */, + C015CB1B2011388C00C6FD82 /* register_emptyusernamepass.json in Resources */, + C015CB1E20113CAB00C6FD82 /* getPostsWith_success.json in Resources */, + C04C3802200EA99200C6EFC6 /* login_nonexistinguser.json in Resources */, + C04C3800200EA6F400C6EFC6 /* login_wrongpass.json in Resources */, + C04C37FD200E94AE00C6EFC6 /* login_success.json in Resources */, + C0109D992013ECE0008BDA69 /* upvote_success.json in Resources */, + C05DA1D92013FA34003C631F /* getPosts_topPosts.json in Resources */, + C015CB17201132DE00C6FD82 /* register_userexists.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0AC6AC0CAA2207AE56408B2B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SEDaily-IOSTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 169806A21F8EED850075D8AD /* Swiftgen Localize Strings Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Swiftgen Localize Strings Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if which \"$PODS_ROOT\"/SwiftGen/bin/swiftgen >/dev/null; then\nset -e\n\"$PODS_ROOT\"/SwiftGen/bin/swiftgen strings -t structured-swift3 \"$PROJECT_DIR/SEDaily-IOS/Base.lproj/Localizable.strings\" --output \"$PROJECT_DIR/Constants/L10nEnum.swift\"\nelse\necho \"warning: SwiftGen not installed, download it from https://github.com/SwiftGen/SwiftGen\"\nfi"; + }; + 16D67C4B1F33AD1D0065E838 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "./Fabric.framework/run f0ec5c4da42e81dc990c190bf79ca81fd9fdcf7d 8f1f2cea85347d8df665cfe1460a1391be0d1c99e81197b80ef43259ec294b0d"; + }; + 1E884C331FA428D400400781 /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\""; + }; + 43BA1A9ED42DE22C48E64092 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Mockingjay/Mockingjay.framework", + "${BUILT_PRODUCTS_DIR}/Nimble/Nimble.framework", + "${BUILT_PRODUCTS_DIR}/Quick/Quick.framework", + "${BUILT_PRODUCTS_DIR}/URITemplate/URITemplate.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Mockingjay.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Nimble.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Quick.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/URITemplate.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOSTests/Pods-SEDaily-IOSTests-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 6A12E8C003C75BEEE01B2AA0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-SEDaily-IOS-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AE5F616C12D0339AD2CE40F0 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/ActiveLabel/ActiveLabel.framework", + "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", + "${BUILT_PRODUCTS_DIR}/Disk/Disk.framework", + "${BUILT_PRODUCTS_DIR}/Down/Down.framework", + "${BUILT_PRODUCTS_DIR}/Eureka/Eureka.framework", + "${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework", + "${BUILT_PRODUCTS_DIR}/IQKeyboardManagerSwift/IQKeyboardManagerSwift.framework", + "${BUILT_PRODUCTS_DIR}/KTResponsiveUI/KTResponsiveUI.framework", + "${BUILT_PRODUCTS_DIR}/Kingfisher/Kingfisher.framework", + "${BUILT_PRODUCTS_DIR}/KoalaTeaFlowLayout/KoalaTeaFlowLayout.framework", + "${BUILT_PRODUCTS_DIR}/KoalaTeaPlayer/KoalaTeaPlayer.framework", + "${BUILT_PRODUCTS_DIR}/MBProgressHUD/MBProgressHUD.framework", + "${BUILT_PRODUCTS_DIR}/Pageboy/Pageboy.framework", + "${BUILT_PRODUCTS_DIR}/PopupDialog/PopupDialog.framework", + "${BUILT_PRODUCTS_DIR}/PureLayout/PureLayout.framework", + "${BUILT_PRODUCTS_DIR}/Reusable/Reusable.framework", + "${BUILT_PRODUCTS_DIR}/SideMenu/SideMenu.framework", + "${BUILT_PRODUCTS_DIR}/Skeleton/Skeleton.framework", + "${BUILT_PRODUCTS_DIR}/SnapKit/SnapKit.framework", + "${BUILT_PRODUCTS_DIR}/StatefulViewController/StatefulViewController.framework", + "${BUILT_PRODUCTS_DIR}/SwiftIcons/SwiftIcons.framework", + "${BUILT_PRODUCTS_DIR}/SwiftMoment/SwiftMoment.framework", + "${BUILT_PRODUCTS_DIR}/SwiftSoup/SwiftSoup.framework", + "${BUILT_PRODUCTS_DIR}/SwifterSwift/SwifterSwift.framework", + "${BUILT_PRODUCTS_DIR}/SwiftyBeaver/SwiftyBeaver.framework", + "${BUILT_PRODUCTS_DIR}/SwiftyJSON/SwiftyJSON.framework", + "${BUILT_PRODUCTS_DIR}/Tabman/Tabman.framework", + "${BUILT_PRODUCTS_DIR}/UIFontComplete/UIFontComplete.framework", + "${BUILT_PRODUCTS_DIR}/WaitForIt/WaitForIt.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ActiveLabel.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Disk.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Down.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Eureka.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/IQKeyboardManagerSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KTResponsiveUI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Kingfisher.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KoalaTeaFlowLayout.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/KoalaTeaPlayer.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MBProgressHUD.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Pageboy.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PopupDialog.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PureLayout.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Reusable.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SideMenu.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Skeleton.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SnapKit.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/StatefulViewController.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftIcons.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftMoment.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftSoup.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwifterSwift.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyBeaver.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Tabman.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/UIFontComplete.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WaitForIt.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-SEDaily-IOS/Pods-SEDaily-IOS-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1686FBF91F009EC00088A6C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9672AA52202929C90020981F /* Author.swift in Sources */, + 96F35745201BFB0200E8B6E9 /* RelatedLink.swift in Sources */, + 965E165820ABD74900F2E4E4 /* FeedItem.swift in Sources */, + 960BFEC320239F0E0073DAB2 /* Comment.swift in Sources */, + 965798A52093FDE600104F8F /* FeedItemCell.swift in Sources */, + 1671BD58200D361900E6ED3B /* SubscriptionModel.swift in Sources */, + 96C8A9AB2091038100489EE4 /* FeedListViewController.swift in Sources */, + 1688CB4C2006BDDA00440095 /* APIStripeExtension.swift in Sources */, + 164FE9E11F02EE67009419CA /* LoginViewController.swift in Sources */, + 161791FC1FC4DA7200A1287E /* OfflineDownloadsManager.swift in Sources */, + 1649A78A1F204EC6005C4A6E /* ContainerViewController.swift in Sources */, + C0590D78200AC2A500A00E4D /* NetworkService.swift in Sources */, + 960BFEBB202109800073DAB2 /* AskForReview.swift in Sources */, + 164FE9EF1F03065C009419CA /* NavigationControllerExtension.swift in Sources */, + 165484551F902D3F005AEA23 /* GeneralCollectionViewController.swift in Sources */, + 1E638E561FC794AC00A29BDC /* ProgressIndicator.swift in Sources */, + 161F3DE61F61F73D00A8F825 /* SearchTableViewController.swift in Sources */, + 1E2BFDCD2017C74A00E6DE0A /* TestHookBoolTableViewCell.swift in Sources */, + 167AFAB71F043F1100A1332F /* HeaderView.swift in Sources */, + 16D67C4A1F33AC620065E838 /* AnswersTracker.swift in Sources */, + 164FE9E51F02EE83009419CA /* API.swift in Sources */, + 164FE9E91F02F049009419CA /* Stylesheet.swift in Sources */, + 960BFEBF20226B620073DAB2 /* CommentsViewController.swift in Sources */, + 96B6168820C0883C00DF3CF6 /* ForumListViewController.swift in Sources */, + 01516A04207310C900E9E743 /* NotificationTableViewCell.swift in Sources */, + 1671BD5A200D3D7800E6ED3B /* SubscriptionStatusViewController.swift in Sources */, + 96BD1C88208E9497006C5E1D /* TestHookBool.swift in Sources */, + 43CE140E226070FB00B57CFA /* PlayProgressModelController.swift in Sources */, + 96A7E2CF20951EBD002C92EC /* ThreadHeaderView.swift in Sources */, + 16CE698F1F98029E0057BAC3 /* PodcastDetailViewController.swift in Sources */, + 1E286DE31FE4C5BA00644C1D /* TestHookEventTableViewCell.swift in Sources */, + 964090BF2093B97D00CFF5C4 /* PodcastLite.swift in Sources */, + 963144DE208FEA8000EA13F1 /* ForumThread.swift in Sources */, + 16AD3E0C1F9B13EE0084C545 /* FilterObject.swift in Sources */, + 1671BD56200D278F00E6ED3B /* PurchaseSubscriptionViewController.swift in Sources */, + 16AD3E0A1F9B138D0084C545 /* PodcastViewModel.swift in Sources */, + 96B6168B20C0A59500DF3CF6 /* ForumThreadCell.swift in Sources */, + 16B147B11F16BF9C00433A42 /* AudioOverlayViewController.swift in Sources */, + 164C71051F021AC8003803BC /* CustomTabViewController.swift in Sources */, + 164FE9DC1F02DAB5009419CA /* PodcastCollectionViewCell.swift in Sources */, + 961A378D208FAFEF0050EF80 /* ForumThreadLite.swift in Sources */, + 962F5365201E3C7900897A6E /* RelatedLinksViewController.swift in Sources */, + 960BFEC7202539180073DAB2 /* CommentReplyTableViewCell.swift in Sources */, + 1EABD8F51FB430AB00959859 /* DebugTabViewController.swift in Sources */, + 1E286DE91FE4C65200644C1D /* TestHookEvent.swift in Sources */, + 960BFEC9202539640073DAB2 /* CommentTableViewCell.swift in Sources */, + 16D766BA1F06B4850066C143 /* AudioView.swift in Sources */, + 164FE9EB1F02F340009419CA /* Helpers.swift in Sources */, + 16FA84031F8D323700A45D9B /* SkeletonCollectionView.swift in Sources */, + 166036211F266FF300A22B7B /* Notifications.swift in Sources */, + 169806A61F8EF3970075D8AD /* L10nEnum.swift in Sources */, + 01BB1D6D1F29999E004A912E /* PodcastPageViewController.swift in Sources */, + 162FFD401FBE200E0026288D /* DiskKeys.swift in Sources */, + 16307FCC1F8FEE3A001783CB /* PodcastViewModelController.swift in Sources */, + 16AD3E0E1F9B14130084C545 /* PodcastDataSource.swift in Sources */, + 01707421207192C0002E6E3C /* NotificationsTableViewController.swift in Sources */, + 164FE9ED1F02F7E2009419CA /* UIButtonExtension.swift in Sources */, + 1EABD8F61FB430AB00959859 /* TestHook.swift in Sources */, + 9662FC0A20AF21FA00CFA8DF /* BaseFeedItem.swift in Sources */, + 1E286DE51FE4C63200644C1D /* TestHookManager.swift in Sources */, + 960BFEC52023C1330073DAB2 /* CommentsResponse.swift in Sources */, + 1E706BD21FD620B100D44AB2 /* BookmarkCollectionViewController.swift in Sources */, + 96D268F720B2250B002F95BD /* RelatedLinkWebVC.swift in Sources */, + 16307FCA1F8FDD02001783CB /* Podcast.swift in Sources */, + 1EB74FFC1FE5E1AF004B733E /* StateBookmarkView.swift in Sources */, + 16F3A1BA1F90918D00364709 /* PodcastRepository.swift in Sources */, + 1686FC011F009EC00088A6C1 /* AppDelegate.swift in Sources */, + 9610313E20A353B400A2D2D5 /* AnalyticsHelper.swift in Sources */, + 161F3DEA1F62703100A8F825 /* PodcastTableViewCell.swift in Sources */, + 164FE9E71F02F02F009419CA /* UserModel.swift in Sources */, + 1E706BD41FD62E0300D44AB2 /* BookmarkViewModelController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1686FC0D1F009EC00088A6C1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */, + C0109DA02013F6A5008BDA69 /* RegisterTests.swift in Sources */, + 1686FC161F009EC00088A6C1 /* SEDailyIOSTests.swift in Sources */, + C0E64BB41FCB3F9100753AF0 /* UserModelTests.swift in Sources */, + C0E64BB71FCB406000753AF0 /* UserDefaultsMock.swift in Sources */, + C0109D9E2013F629008BDA69 /* LoginTests.swift in Sources */, + C079B6541FCA07F800B4B304 /* HelpersTests.swift in Sources */, + C0590D7A200AD36500A00E4D /* VotingTests.swift in Sources */, + C0109DA22013F6D8008BDA69 /* PostsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 1686FC131F009EC00088A6C1 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1686FBFC1F009EC00088A6C1 /* SEDaily-IOS */; + targetProxy = 1686FC121F009EC00088A6C1 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 1686FC041F009EC00088A6C1 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 1686FC051F009EC00088A6C1 /* Base */, + 1E44AF001F87ACF500221B22 /* fr */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 1686FC091F009EC00088A6C1 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 1686FC0A1F009EC00088A6C1 /* Base */, + 1E44AEFF1F87ACF500221B22 /* fr */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; + 1E44AF051F87B08D00221B22 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 1E44AF061F87B09100221B22 /* Base */, + 1E44AF081F87B0A700221B22 /* fr */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 1686FC181F009EC00088A6C1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_ACTIVITY_MODE = ""; + "DEBUG_ACTIVITY_MODE[arch=*]" = disable; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1686FC191F009EC00088A6C1 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_ACTIVITY_MODE = ""; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1686FC1B1F009EC00088A6C1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9FDA28C254F62BB468401B75 /* Pods-SEDaily-IOS.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 8DPYE775WZ; + INFOPLIST_FILE = "SEDaily-IOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + }; + name = Debug; + }; + 1686FC1C1F009EC00088A6C1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 675FBEB71FD81FBA8767227A /* Pods-SEDaily-IOS.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 8DPYE775WZ; + INFOPLIST_FILE = "SEDaily-IOS/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 10.3; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + }; + name = Release; + }; + 1686FC1E1F009EC00088A6C1 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 995F64385352A79BA0DF7DEB /* Pods-SEDaily-IOSTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + DEVELOPMENT_TEAM = 6MS9QWALYV; + INFOPLIST_FILE = "SEDaily-IOSTests/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily-IOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SEDaily-IOS.app/SEDaily-IOS"; + }; + name = Debug; + }; + 1686FC1F1F009EC00088A6C1 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A2EFA433B346D70E958FFC73 /* Pods-SEDaily-IOSTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(TEST_HOST)"; + DEVELOPMENT_TEAM = 6MS9QWALYV; + INFOPLIST_FILE = "SEDaily-IOSTests/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = "koala-tea.SEDaily-IOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 4.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SEDaily-IOS.app/SEDaily-IOS"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1686FBF81F009EC00088A6C1 /* Build configuration list for PBXProject "SEDaily-IOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1686FC181F009EC00088A6C1 /* Debug */, + 1686FC191F009EC00088A6C1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1686FC1A1F009EC00088A6C1 /* Build configuration list for PBXNativeTarget "SEDaily-IOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1686FC1B1F009EC00088A6C1 /* Debug */, + 1686FC1C1F009EC00088A6C1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1686FC1D1F009EC00088A6C1 /* Build configuration list for PBXNativeTarget "SEDaily-IOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1686FC1E1F009EC00088A6C1 /* Debug */, + 1686FC1F1F009EC00088A6C1 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1686FBF51F009EC00088A6C1 /* Project object */; +} diff --git a/SEDaily-IOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SEDaily-IOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/SEDaily-IOS.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/SEDaily-IOS.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SEDaily-IOS.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/SEDaily-IOS.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/SEDaily-IOS/API.swift b/SEDaily-IOS/API.swift index fab7544..dfc1f0c 100644 --- a/SEDaily-IOS/API.swift +++ b/SEDaily-IOS/API.swift @@ -6,6 +6,7 @@ // Copyright © 2017 Koala Tea. All rights reserved. // +import SwiftyBeaver import UIKit import Alamofire import SwiftyJSON @@ -13,320 +14,1019 @@ import Fabric import Crashlytics extension API { - enum Headers { - static let contentType = "Content-Type" - static let authorization = "Authorization" - static let x_www_form_urlencoded = "application/x-www-form-urlencoded" - static let bearer = "Bearer " - } - - enum Endpoints { - static let posts = "/posts" - static let recommendations = "/posts/recommendations" - static let login = "/auth/login" - static let register = "/auth/register" - static let upvote = "/upvote" - static let downvote = "/downvote" - } - - enum Types { - static let new = "new" - static let top = "top" - static let recommended = "recommended" - } - - enum TagIds { - static let business = "1200" - } - - enum Params { - static let bearer = "Bearer" - static let lastUpdatedBefore = "lastUpdatedBefore" - static let createdAtBefore = "createdAtBefore" - static let active = "active" - static let platform = "platform" - static let deviceToken = "deviceToken" - static let accessToken = "accessToken" - static let type = "type" - static let username = "username" - static let password = "password" - static let token = "token" - static let tags = "tags" - static let categories = "categories" - static let search = "search" - } + enum Headers { + static let contentType = "Content-Type" + static let authorization = "Authorization" + static let x_www_form_urlencoded = "application/x-www-form-urlencoded" + static let bearer = "Bearer " + } + + enum Endpoints { + static let posts = "/posts" + static let recommendations = "/posts/recommendations" + static let forum = "/forum" + static let feed = "/feed" + static let listened = "/listened" + + static let login = "/auth/login" + static let register = "/auth/register" + static let upvote = "/upvote" + static let downvote = "/downvote" + static let usersMe = "/users/me" + static let favorites = "/favorites" + static let favorite = "/favorite" + static let unfavorite = "/unfavorite" + static let myBookmarked = "/users/me/bookmarked" + static let relatedLinks = "/related-links" + static let relatedLink = "/related-link" + static let comments = "/comments" + static let createComment = "/comment" + static let users = "/users" + static let topicsForPost = "/topics" + } + + enum Types { + static let new = "new" + static let top = "top" + static let recommended = "recommended" + } + + enum TagIds { + static let business = "1200" + } + + enum Params { + static let bearer = "Bearer" + static let lastUpdatedBefore = "lastUpdatedBefore" + static let createdAtBefore = "createdAtBefore" + static let lastActivityBefore = "lastActivityBefore" + static let active = "active" + static let platform = "platform" + static let deviceToken = "deviceToken" + static let accessToken = "accessToken" + static let type = "type" + static let email = "email" + static let username = "username" + static let password = "password" + static let token = "token" + static let tags = "tags" + static let categories = "categories" + static let search = "search" + static let commentContent = "content" + static let entityType = "entityType" + static let parentCommentId = "parentCommentId" + static let postId = "postId" + static let topic = "topic" + static let relatedLinkTitle = "title" + static let relatedLinkURL = "url" + } } class API { - let rootURL: String = "https://software-enginnering-daily-api.herokuapp.com/api"; - - static let sharedInstance: API = API() - private init() {} + private let prodRootURL = "https://software-enginnering-daily-api.herokuapp.com/api" + private let stagingRootURL = "https://sedaily-backend-staging.herokuapp.com/api" + + var rootURL: String { + #if DEBUG + if let useStagingEndpointTestHook = TestHookManager.testHookBool(id: TestHookId.useStagingEndpoint), + useStagingEndpointTestHook.value { + return stagingRootURL + } + #endif + return prodRootURL + } + } extension API { - // MARK: Auth - func login(firstName: String, lastName: String, email: String, password: String, completion: @escaping (_ success: Bool?) -> Void) { - let urlString = rootURL + Endpoints.login - - let _headers : HTTPHeaders = [Headers.contentType:Headers.x_www_form_urlencoded] - var params = [String: String]() - params[Params.username] = email - params[Params.password] = password - - Alamofire.request(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody , headers: _headers).responseJSON { response in - switch response.result { - case .success: - let jsonResponse = response.result.value as! NSDictionary - - if let message = jsonResponse["message"] { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) - completion(false) - Tracker.logLoginError(string: String(describing: message)) - return - } - - if let token = jsonResponse["token"] as? String { - let user = User(firstName: firstName, lastName: lastName, email: email, token: token) - UserManager.sharedInstance.setCurrentUser(to: user) - - NotificationCenter.default.post(name: .loginChanged, object: nil) - completion(true) - } - case .failure(let error): - log.error(error) + // MARK: Auth + func login(usernameOrEmail: String, password: String, completion: @escaping (_ success: Bool) -> Void) { + let urlString = rootURL + Endpoints.login + + let _headers: HTTPHeaders = [Headers.contentType: Headers.x_www_form_urlencoded] + var params = [String: String]() + params[Params.username] = usernameOrEmail + params[Params.password] = password + + networkRequest(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + completion(false) + Tracker.logLoginError(string: "Error: result value is not a NSDictionary") + return + } + + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(false) + Tracker.logLoginError(string: String(describing: message)) + return + } + + if let token = jsonResponse["token"] as? String { + let user = User(token: token) + UserManager.sharedInstance.setCurrentUser(to: user) + + // Clear disk cache + PodcastDataSource.clean(diskKey: .PodcastFolder) + NotificationCenter.default.post(name: .loginChanged, object: nil) + + // Check for subscription or other info + self.loadUserInfo() + + completion(true) + } + case .failure(let error): + log.error(error) + + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + Tracker.logLoginError(error: error) + completion(false) + } + } + } + + func register(email: String, username: String, password: String, completion: @escaping (_ success: Bool?) -> Void) { + let urlString = rootURL + Endpoints.register + + let _headers: HTTPHeaders = [Headers.contentType: Headers.x_www_form_urlencoded] + var params = [String: String]() + params[Params.username] = username + params[Params.email] = email + params[Params.password] = password + + networkRequest(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logRegisterError(string: "Error: result value is not a NSDictionary") + completion(false) + return + } + + if let message = jsonResponse["message"] { + log.error(message) + + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + Tracker.logRegisterError(string: String(describing: message)) + completion(false) + return + } + + + if let token = jsonResponse["token"] as? String { + let user = User(username: username, email: email, token: token) + UserManager.sharedInstance.setCurrentUser(to: user) + NotificationCenter.default.post(name: .loginChanged, object: nil) + self.loadUserInfo() + completion(true) + } + case .failure(let error): + log.error(error) + + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + Tracker.logRegisterError(error: error) + completion(false) + } + } + } + + func getUser(userId: String, completion: @escaping (_ user: User?) -> Void) { + let urlString = rootURL + Endpoints.users + "/" + userId + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + + let _headers: HTTPHeaders = [ + Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + + Alamofire.request(urlString, method: .get, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion(nil) + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(nil) + return + } + + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + return + } + // Get user details + + let this = JSON(responseData) + + guard let jsonData = try? this.rawData() else { return } + do { + + let user = try JSONDecoder().decode(User.self, from: jsonData) + + completion(user) + + } catch { + log.error(error) + + } + + case .failure(let error): + log.error(error.localizedDescription) + } + } + } + + func loadUserInfo(completion: ((SubscriptionModel?) -> Void)? = nil) { + let urlString = rootURL + Endpoints.usersMe + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + + let _headers: HTTPHeaders = [ + Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + + Alamofire.request(urlString, method: .get, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion?(nil) + return + } + + + + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion?(nil) + return + } + + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + return + } + // Get user details + + var hasSubscription: Bool = false + if let subscriptionDictionary = jsonResponse["subscription"] as? [String: Any?] { + hasSubscription = true + } + + let this = JSON(responseData) + + + + guard let jsonData = try? this.rawData() else { return } + do { + + var user = try JSONDecoder().decode(User.self, from: jsonData) + user.hasPremium = hasSubscription + user.token = userToken + UserManager.sharedInstance.setCurrentUser(to: user) + NotificationCenter.default.post(name: .loginChanged, object: nil) + + } catch { + log.error(error) + + } + + + + let json = JSON(responseData) + let subscriptionJSON = json + + + do { + let newObject = try JSONDecoder().decode(SubscriptionModel.self, from: jsonData) + completion?(newObject) + } catch { + log.error("Can't decode to subscription model") + } + case .failure(let error): + log.error(error.localizedDescription) + } + } + } +} - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) - Tracker.logLoginError(error: error) - completion(false) - } - } - } - - func register(firstName: String, lastName: String, email: String, password: String, completion: @escaping (_ success: Bool?) -> Void) { - let urlString = rootURL + Endpoints.register - - let _headers : HTTPHeaders = [Headers.contentType:Headers.x_www_form_urlencoded] - var params = [String: String]() - params[Params.username] = email - params[Params.password] = password - - Alamofire.request(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody , headers: _headers).responseJSON { response in - switch response.result { - case .success: - let jsonResponse = response.result.value as! NSDictionary - - if let message = jsonResponse["message"] { - log.error(message) - - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) - Tracker.logRegisterError(string: String(describing: message)) - completion(false) - return - } - - if let token = jsonResponse["token"] as? String { - let user = User(firstName: firstName, lastName: lastName, email: email, token: token) - UserManager.sharedInstance.setCurrentUser(to: user) - - NotificationCenter.default.post(name: .loginChanged, object: nil) - completion(true) - } - case .failure(let error): - log.error(error) - - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) - Tracker.logRegisterError(error: error) - completion(false) - } - } - } +typealias PodcastModel = Podcast +// MARK: Search +extension API { + func getPostsWith(searchTerm: String, + createdAtBefore beforeDate: String = "", + onSuccess: @escaping ([Podcast]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + + var params = [String: String]() + params[Params.search] = searchTerm + params[Params.createdAtBefore] = beforeDate + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken + ] + + networkRequest(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + var data: [PodcastModel] = [] + let this = JSON(responseData) + + for (_, subJson):(String, JSON) in this { + guard let jsonData = try? subJson.rawData() else { continue } + let newObject = try? JSONDecoder().decode(PodcastModel.self, from: jsonData) + + if let newObject = newObject { + data.append(newObject) + } + } + onSuccess(data) + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } +} + +extension API { + func getTopicsForPost(podcastId: String, + onSuccess: @escaping ([Topic]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.topicsForPost + + var params = [String: String]() + params[Params.postId] = podcastId + + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken + ] + + networkRequest(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + let jsonData = JSON(responseData) + var topicModels = [Topic]() + jsonData.forEach({ (_, itemJsonData) in + if let rawData = try? itemJsonData.rawData(), + let podcast = try? JSONDecoder().decode(Topic.self, from: rawData) { + topicModels.append(podcast) + } + }) + onSuccess(topicModels) + + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } } -typealias podcastModel = Podcast -// MARK: Search extension API { - func getPostsWith(searchTerm: String, - createdAtBefore beforeDate: String = "", - onSucces: @escaping ([Podcast]) -> Void, - onFailure: @escaping (APIError?) -> Void) { - let urlString = rootURL + Endpoints.posts - - var params = [String: String]() - params[Params.search] = searchTerm - params[Params.createdAtBefore] = beforeDate - - let user = UserManager.sharedInstance.getActiveUser() - let userToken = user.token - let _headers : HTTPHeaders = [ - Headers.authorization:Headers.bearer + userToken, - ] - - Alamofire.request(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in - switch response.result { - case .success: - guard let responseData = response.data else { - // Handle error here - log.error("response has no data") - onFailure(.NoResponseDataError) - return - } - - var data: [podcastModel] = [] - let this = JSON(responseData) - for (_, subJson):(String, JSON) in this { - guard let jsonData = try? subJson.rawData() else { continue } - let newObject = try? JSONDecoder().decode(podcastModel.self, from: jsonData) - if let newObject = newObject { - data.append(newObject) - } - } - onSucces(data) - case .failure(let error): - log.error(error.localizedDescription) - Tracker.logGeneralError(error: error) - onFailure(.GeneralFailure) - } - } - } + func getPostsFor(topic: String, + createdAtBefore beforeDate: String = "", + onSuccess: @escaping ([Podcast]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + + var params = [String: String]() + params[Params.createdAtBefore] = beforeDate + params[Params.topic] = topic + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken + ] + + networkRequest(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + var data: [PodcastModel] = [] + let this = JSON(responseData) + + for (_, subJson):(String, JSON) in this { + guard let jsonData = try? subJson.rawData() else { continue } + let newObject = try? JSONDecoder().decode(PodcastModel.self, from: jsonData) + if let newObject = newObject { + data.append(newObject) + } + } + onSuccess(data) + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } } + // MARK: - MVVM Getters extension API { - func getPosts(type: String = "", - createdAtBefore beforeDate: String = "", - tags: String = "-1", - categories: String = "", - onSucces: @escaping ([Podcast]) -> Void, - onFailure: @escaping (APIError?) -> Void) { - var type = type - - let user = UserManager.sharedInstance.getActiveUser() - let userToken = user.token - let _headers : HTTPHeaders = [ - Headers.authorization:Headers.bearer + userToken, - ] + func getPost(podcastId: String, completion: @escaping (_ success: Bool, _ result: Podcast?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + Alamofire.request(urlString, method: .get, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + log.error("response has no data") + completion(false, nil) + return + } + + let jsonData = JSON(responseData) + + guard let data = try? jsonData.rawData() else { + log.error("response has no data") + completion(false, nil) + return + } + let podcast = try? JSONDecoder().decode(PodcastModel.self, from: data) + completion(true, podcast) + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + completion(false, nil) + } + } + } + + func getPosts(type: String = "", + createdAtBefore beforeDate: String = "", + tags: String = "-1", + categories: String = "", + onSuccess: @escaping ([Podcast]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + var type = type + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken + ] + + if userToken.isEmpty && type == PodcastTypes.recommended.rawValue { + type = PodcastTypes.top.rawValue + } + + var urlString = rootURL + API.Endpoints.posts + if type == PodcastTypes.recommended.rawValue { + urlString = rootURL + Endpoints.recommendations + } + + // Params + var params = [String: String]() + params[Params.type] = type + if beforeDate != "" && type != PodcastTypes.recommended.rawValue { + params[Params.createdAtBefore] = beforeDate + } + + // @TODO: Allow for an array and join the array + if !tags.isEmpty { + params[Params.tags] = tags + } + + if !categories.isEmpty { + params[Params.categories] = categories + } + + networkRequest(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + var data: [PodcastModel] = [] + let this = JSON(responseData) + + for (_, subJson):(String, JSON) in this { + guard let jsonData = try? subJson.rawData() else { continue } + let newObject = try? JSONDecoder().decode(PodcastModel.self, from: jsonData) + if var newObject = newObject { + newObject.type = type + data.append(newObject) + } + } + onSuccess(data) + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } +} + +// MARK: Listened History +extension API { + func markAsListened(postId: String) { + // http://localhost:4040/api/posts/5a57b6ffe9b21f96de35dabb/listened + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken + ] + + let urlString = rootURL + API.Endpoints.posts + "/" + postId + API.Endpoints.listened + networkRequest(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + + switch response.result { + case .success: + print("success") + case .failure(let error): + log.error(error) + // onFailure(nil) + } + } + } +} +//typealias ForumThreadModel = ForumThread + +// MARK: Forum + + + +// MARK: Feed + + +typealias RelatedLinkModel = RelatedLink + +// MARK: Related Links +extension API { + func getRelatedLinks(podcastId: String, onSuccess: @escaping ([RelatedLink]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + Endpoints.relatedLinks + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + networkRequest(urlString, method: .get, parameters: nil, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + do { + let data: [RelatedLinkModel] = try JSONDecoder().decode([RelatedLinkModel].self, from: responseData) + onSuccess(data) + } catch let jsonErr { + onFailure(.NoResponseDataError) + + } + + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } + + func addRelatedLink(podcastId: String, title: String, url: String, onSuccess: @escaping () -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + Endpoints.relatedLink + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + var params = [String: String]() + params[Params.relatedLinkTitle] = title + params[Params.relatedLinkURL] = url + + networkRequest(urlString, method: .post, parameters: params, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + onSuccess() + + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } + + func upvoteRelatedLink(entityId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { + let urlString = self.rootURL + Endpoints.relatedLinks + "/" + entityId + Endpoints.upvote + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + networkRequest(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion(false, nil) + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(false, nil) + return + } + + if let active = jsonResponse["active"] as? Bool { + completion(true, active) + } + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + completion(false, nil) + } + } + } + +} - if userToken.isEmpty && type == PodcastTypes.recommended.rawValue { - type = PodcastTypes.top.rawValue - } - - var urlString = self.rootURL + API.Endpoints.posts - if type == PodcastTypes.recommended.rawValue { - urlString = self.rootURL + Endpoints.recommendations - } - - // Params - var params = [String: String]() - params[Params.type] = type - if beforeDate != "" && type != PodcastTypes.recommended.rawValue { - params[Params.createdAtBefore] = beforeDate - } - - // @TODO: Allow for an array and join the array - if (tags != "") { - params[Params.tags] = tags - } - - if (categories != "") { - params[Params.categories] = categories - } +typealias CommentModel = Comment +// MARK: Comments +extension API { + + // get Comments + func getComments(rootEntityId: String, onSuccess: @escaping ([Comment]) -> Void, + onFailure: @escaping (APIError?) -> Void) { + let urlString = self.rootURL + Endpoints.comments + "/forEntity/" + rootEntityId + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + + networkRequest(urlString, method: .get, parameters: nil, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + // Handle error here + log.error("response has no data") + onFailure(.NoResponseDataError) + return + } + + do { + let data: CommentsResponse = try JSONDecoder().decode(CommentsResponse.self, from: responseData) + onSuccess(data.result) + } catch let jsonErr { + onFailure(.NoResponseDataError) + } + + case .failure(let error): + log.error(error.localizedDescription) + Tracker.logGeneralError(error: error) + onFailure(.GeneralFailure) + } + } + } + + // create Comment + func createComment(rootEntityId: String, parentComment: Comment?, commentContent: String, onSuccess: @escaping () -> Void, + onFailure: @escaping (APIError?) -> Void) { + + let urlString = self.rootURL + Endpoints.comments + "/forEntity/" + rootEntityId + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + var params = [String: String]() + params[Params.commentContent] = commentContent + params[Params.entityType] = "forumthread" + // This is included if we are replying to a comment + if let parentComment = parentComment { + params[Params.parentCommentId] = parentComment._id + } + + networkRequest(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + + switch response.result { + case .success: + onSuccess() + case .failure(let error): + log.error(error) + onFailure(nil) + } + } + } +} - Alamofire.request(urlString, method: .get, parameters: params, headers: _headers).responseJSON { response in - switch response.result { - case .success: - guard let responseData = response.data else { - // Handle error here - log.error("response has no data") - onFailure(.NoResponseDataError) - return - } - - var data: [podcastModel] = [] - let this = JSON(responseData) - for (_, subJson):(String, JSON) in this { - guard let jsonData = try? subJson.rawData() else { continue } - let newObject = try? JSONDecoder().decode(podcastModel.self, from: jsonData) - if var newObject = newObject { - newObject.type = type - data.append(newObject) - } - } - onSucces(data) - case .failure(let error): - log.error(error.localizedDescription) - Tracker.logGeneralError(error: error) - onFailure(.GeneralFailure) - } - } - } +// MARK: Bookmarks +extension API { + /// Network call to get bookmarks for the current user + /// + /// - Parameter completion: Callback when the network call completes. + func podcastBookmarks(completion: @escaping (_ success: Bool, _ results: [PodcastModel]?) -> Void) { + let urlString = self.rootURL + Endpoints.myBookmarked + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let headers = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + Alamofire.request( + urlString, + method: .get, + parameters: nil, + encoding: URLEncoding.httpBody, + headers: headers).responseJSON { response in + switch response.result { + case .success: + guard let responseData = response.data else { + log.error("response has no data") + completion(false, nil) + return + } + + let jsonData = JSON(responseData) + var podcastModels = [PodcastModel]() + jsonData.forEach({ (_, itemJsonData) in + if let rawData = try? itemJsonData.rawData(), + let podcast = try? JSONDecoder().decode(PodcastModel.self, from: rawData) { + podcastModels.push(podcast) + } + }) + + completion(true, podcastModels) + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage( + title: Helpers.Alerts.error, + message: error.localizedDescription, + completionHandler: nil) + completion(false, nil) + } + } + } + + /// Network call to bookmark or unbookmark a pod cast. + /// + /// - Parameters: + /// - value: True to bookmark the pod cast, false to unbookmark the pod cast + /// - podcastId: The id of the pod cast + /// - completion: Callback when network call completes + func setBookmarkPodcast( + value: Bool, + podcastId: String, + completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + + (value ? Endpoints.favorite : Endpoints.unfavorite) + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let headers = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + Alamofire.request( + urlString, + method: .post, + parameters: nil, + encoding: URLEncoding.httpBody, + headers: headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion(false, nil) + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage( + title: Helpers.Alerts.error, + message: String(describing: message), + completionHandler: nil) + completion(false, nil) + return + } + + if let active = jsonResponse["active"] as? Bool { + completion(true, active) + } + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage( + title: Helpers.Alerts.error, + message: error.localizedDescription, + completionHandler: nil) + completion(false, nil) + } + } + } } // MARK: Voting extension API { - func upvotePodcast(podcastId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { - let urlString = rootURL + Endpoints.posts + "/" + podcastId + Endpoints.upvote - - let user = UserManager.sharedInstance.getActiveUser() - let userToken = user.token - let _headers : HTTPHeaders = [ - Headers.authorization:Headers.bearer + userToken, - Headers.contentType:Headers.x_www_form_urlencoded - ] - - Alamofire.request(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody , headers: _headers).responseJSON { response in - switch response.result { - case .success: - let jsonResponse = response.result.value as! NSDictionary + func upvoteForum(entityId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { + let urlString = self.rootURL + Endpoints.forum + "/" + entityId + Endpoints.upvote + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + networkRequest(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion(false, nil) + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(false, nil) + return + } + + if let active = jsonResponse["active"] as? Bool { + completion(true, active) + } + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + completion(false, nil) + } + } + } + + func upvotePodcast(podcastId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + Endpoints.upvote + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" - if let message = jsonResponse["message"] { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) - completion(false, nil) - return - } - - if let active = jsonResponse["active"] as? Bool { - completion(true, active) - } - case .failure(let error): - log.error(error) - Tracker.logGeneralError(error: error) - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) - completion(false, nil) - } - } - } - - func downvotePodcast(podcastId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { - let urlString = rootURL + Endpoints.posts + "/" + podcastId + Endpoints.downvote - - let user = UserManager.sharedInstance.getActiveUser() - let userToken = user.token - let _headers : HTTPHeaders = [ - Headers.authorization:Headers.bearer + userToken, - Headers.contentType:Headers.x_www_form_urlencoded - ] - - Alamofire.request(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody , headers: _headers).responseJSON { response in - switch response.result { - case .success: - let jsonResponse = response.result.value as! NSDictionary + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + networkRequest(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + Tracker.logGeneralError(string: "Error result value is not a NSDictionary") + completion(false, nil) + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(false, nil) + return + } + + if let active = jsonResponse["active"] as? Bool { + completion(true, active) + } + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + completion(false, nil) + } + } + } + + func downvotePodcast(podcastId: String, completion: @escaping (_ success: Bool?, _ active: Bool?) -> Void) { + let urlString = self.rootURL + Endpoints.posts + "/" + podcastId + Endpoints.downvote + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + let _headers: HTTPHeaders = [ + Headers.authorization: Headers.bearer + userToken, + Headers.contentType: Headers.x_www_form_urlencoded + ] + + networkRequest(urlString, method: .post, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers).responseJSON { response in + switch response.result { + case .success: + guard let jsonResponse = response.result.value as? NSDictionary else { + completion(false, nil) + Tracker.logGeneralError(string: "Error: result value is not a NSDictionary") + return + } + + if let message = jsonResponse["message"] { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) + completion(false, nil) + return + } + if let active = jsonResponse["active"] as? Bool { + completion(true, active) + } + case .failure(let error): + log.error(error) + Tracker.logGeneralError(error: error) + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) + completion(false, nil) + } + } + } +} - if let message = jsonResponse["message"] { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: String(describing: message), completionHandler: nil) - completion(false, nil) - return - } - if let active = jsonResponse["active"] as? Bool { - completion(true, active) - } - case .failure(let error): - log.error(error) - Tracker.logGeneralError(error: error) - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: error.localizedDescription, completionHandler: nil) - completion(false, nil) - } - } - } +extension API: NetworkService { + func networkRequest(_ urlString: URLConvertible, method: HTTPMethod, parameters: Parameters?, encoding: ParameterEncoding = URLEncoding.default, headers: HTTPHeaders?) -> DataRequest { + return Alamofire.request(urlString, method: method, parameters: parameters, encoding: encoding, headers: headers) + } } + diff --git a/SEDaily-IOS/APIStripeExtension.swift b/SEDaily-IOS/APIStripeExtension.swift new file mode 100644 index 0000000..d7fbade --- /dev/null +++ b/SEDaily-IOS/APIStripeExtension.swift @@ -0,0 +1,81 @@ +// +// APIStripeExtension.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 1/10/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation +import Alamofire + +private enum StripeEndpoints { + static let subscription = "/subscription" +} + +enum StripeParams: String { + case stripeToken + case stripeEmail + case planType + + enum Plans: String { + case monthly + case yearly + } +} + +extension API { + func stripeCreateSubscription(token: String, planType: StripeParams.Plans, completion: @escaping (Error?) -> Void) { + let urlString = rootURL + StripeEndpoints.subscription + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + + let _headers: HTTPHeaders = [ + Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + + var params = [String: String]() + params[StripeParams.stripeToken.rawValue] = token + params[StripeParams.planType.rawValue] = planType.rawValue + + Alamofire.request(urlString, method: .post, parameters: params, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + switch response.result { + case .success: + // Reload user info + self.loadUserInfo() + completion(nil) + case .failure(let error): + completion(error) + } + } + } + + func stripeCancelSubscription(completion: @escaping (Error?) -> Void) { + let urlString = rootURL + StripeEndpoints.subscription + + let user = UserManager.sharedInstance.getActiveUser() + let userToken = user.token ?? "" + + let _headers: HTTPHeaders = [ + Headers.contentType: Headers.x_www_form_urlencoded, + Headers.authorization: Headers.bearer + userToken + ] + + Alamofire.request(urlString, method: .delete, parameters: nil, encoding: URLEncoding.httpBody, headers: _headers) + .validate(statusCode: 200..<300) + .responseJSON { response in + switch response.result { + case .success: + // Reload user info + self.loadUserInfo() + completion(nil) + case .failure(let error): + completion(error) + } + } + } +} diff --git a/SEDaily-IOS/ActionView.swift b/SEDaily-IOS/ActionView.swift new file mode 100644 index 0000000..b13e616 --- /dev/null +++ b/SEDaily-IOS/ActionView.swift @@ -0,0 +1,75 @@ +// +// ActionView.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/1/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit + +class ActionView { + let actionStackView: UIStackView = UIStackView() + let upvoteButton: UIButton = UIButton() + let commentButton: UIButton = UIButton() + let bookmarkButton: UIButton = UIButton() + + var commentShowCallback: ( ()-> Void) = {} + + let upvoteCountLabel: UILabel = UILabel() + + let upvoteStackView: UIStackView = UIStackView() + + func setupComponents(superview: UIView) { + func setupButtons() { + upvoteButton.setImage(UIImage(named: "like_outline"), for: .normal) + upvoteButton.setImage(UIImage(named: "like"), for: .selected) + + bookmarkButton.setImage(UIImage(named: "bookmark_outline"), for: .normal) + bookmarkButton.setImage(UIImage(named: "bookmark"), for: .selected) + + commentButton.setImage(UIImage(named: "comment"), for: .normal) + commentButton.setImage(UIImage(named: "comment"), for: .normal) + } + func setupUpvoteStackView() { + upvoteStackView.alignment = .center + upvoteStackView.axis = .horizontal + upvoteStackView.distribution = .fillEqually + + upvoteStackView.addArrangedSubview(upvoteButton) + upvoteStackView.addArrangedSubview(upvoteCountLabel) + } + + func setupActionStackView() { + actionStackView.alignment = .center + actionStackView.axis = .horizontal + actionStackView.distribution = .fillEqually + + actionStackView.addArrangedSubview(upvoteStackView) + actionStackView.addArrangedSubview(commentButton) + actionStackView.addArrangedSubview(bookmarkButton) + superview.addSubview(actionStackView) + } + setupButtons() + setupUpvoteStackView() + setupActionStackView() + + } + + + func setupContraints() { + upvoteButton.snp.makeConstraints { (make) -> Void in + make.right.equalTo(upvoteCountLabel.snp.left) + make.height.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)).priority(999) + } + bookmarkButton.snp.makeConstraints { (make) -> Void in + + make.height.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)).priority(999) + } + commentButton.snp.makeConstraints { (make) -> Void in + + make.height.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)).priority(999) + } + } +} diff --git a/SEDaily-IOS/AnalyticsHelper.swift b/SEDaily-IOS/AnalyticsHelper.swift new file mode 100644 index 0000000..688986d --- /dev/null +++ b/SEDaily-IOS/AnalyticsHelper.swift @@ -0,0 +1,125 @@ +// +// AnalyticsHelper.swift +// SEDaily-IOS +// +// Created by jason on 5/9/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation +import Firebase + +class Analytics2 { + // TODO: combine with AnswerTracker.swift + // MARK: Page Views + class func notificationPageViewed() { + Analytics.logEvent("notifications_page_viewed", parameters: nil) + } + class func bookmarksPageViewed() { + Analytics.logEvent("bookmarks_page_viewed", parameters: nil) + } + class func loginFormViewed() { + Analytics.logEvent("login_form_viewed", parameters: nil) + } + class func registrationFormViewed() { + Analytics.logEvent("registration_form_viewed", parameters: nil) + } + + class func podcastPlayed(podcastId: String) { + Analytics.logEvent("podcast_episode_played", parameters: [ + AnalyticsParameterItemID: "id-\(podcastId)", + AnalyticsParameterItemName: podcastId, + AnalyticsParameterContentType: "play" + ]) + } + + class func podcastPageViewed(podcastId: String) { + Analytics.logEvent("podcast_episode_viewed", parameters: [ + AnalyticsParameterItemID: "id-\(podcastId)", + AnalyticsParameterItemName: podcastId, + AnalyticsParameterContentType: "view" + ]) + } + class func podcastCommentsViewed(podcastId: String) { + Analytics.logEvent("podcast_comments_viewed", parameters: [ + AnalyticsParameterItemID: "id-\(podcastId)", + AnalyticsParameterItemName: podcastId, + AnalyticsParameterContentType: "view" + ]) + } + class func feedViewed() { + Analytics.logEvent("feed_list_viewed", parameters: nil) + } + + + + class func relatedLinkSafariOpen(url: URL) { + Analytics.logEvent("related_link_in_safari", parameters: [ + AnalyticsParameterItemID: "\(url.absoluteString)", + AnalyticsParameterItemName: url.absoluteString, + AnalyticsParameterContentType: "tapped" + ]) + } + + class func relatedLinkViewed(url: URL) { + Analytics.logEvent("related_link_viewed", parameters: [ + AnalyticsParameterItemID: "\(url.absoluteString)", + AnalyticsParameterItemName: url.absoluteString, + AnalyticsParameterContentType: "view" + ]) + } + + class func newPodcastsListViewed(tabTitle: String) { + Analytics.logEvent("podcast_new_list_viewed", parameters: [ + AnalyticsParameterItemID: "tabTitle-\(tabTitle)", + AnalyticsParameterItemName: tabTitle, + AnalyticsParameterContentType: "view" + ]) + } + class func topPodcastsListViewed(tabTitle: String) { + Analytics.logEvent("podcast_top_list_viewed", parameters: [ + AnalyticsParameterItemID: "tabTitle-\(tabTitle)", + AnalyticsParameterItemName: tabTitle, + AnalyticsParameterContentType: "view" + ]) + } + class func recommendedPodcastsListViewed(tabTitle: String) { + Analytics.logEvent("podcast_recommended_list_viewed", parameters: [ + AnalyticsParameterItemID: "tabTitle-\(tabTitle)", + AnalyticsParameterItemName: tabTitle, + AnalyticsParameterContentType: "view" + ]) + } + + // MARK: Button Presses + class func searchNavButtonPressed() { + Analytics.logEvent("search_button_nav_pressed", parameters: nil) + } + class func loginNavButtonPressed() { + Analytics.logEvent("login_button_nav_pressed", parameters: nil) + } + class func logoutNavButtonPressed() { + Analytics.logEvent("logout_button_nav_pressed", parameters: nil) + } + class func cancelRegistrationButtonPressed() { + Analytics.logEvent("cancel_registration_button_nav_pressed", parameters: nil) + } + class func relatedLinksButtonPressed(podcastId: String) { + Analytics.logEvent("related_links_button_pressed", parameters: [ + AnalyticsParameterItemID: "id-\(podcastId)", + AnalyticsParameterItemName: podcastId, + AnalyticsParameterContentType: "view" + ]) + } + class func bookmarkButtonPressed(podcastId: String) { + Analytics.logEvent("bookmark_button_pressed", parameters: [ + AnalyticsParameterItemID: "id-\(podcastId)", + AnalyticsParameterItemName: podcastId, + AnalyticsParameterContentType: "view" + ]) + } + class func refreshMyBookmarksPressed() { + Analytics.logEvent("bookmark_refresh_button_pressed", parameters: nil) + + } +} diff --git a/SEDaily-IOS/AnswersTracker.swift b/SEDaily-IOS/AnswersTracker.swift index bf0fdf5..2c506fe 100644 --- a/SEDaily-IOS/AnswersTracker.swift +++ b/SEDaily-IOS/AnswersTracker.swift @@ -22,51 +22,64 @@ class Tracker { Answers.logCustomEvent(withName: "Moved_To_Webview", customAttributes: [ - "website": url + "website": url, + "isLoggedIn": UserManager.sharedInstance.isCurrentUserLoggedIn() ] ) } - + class func logPlayPodcast(podcast: PodcastViewModel) { Answers.logCustomEvent(withName: "Podcast_Play", customAttributes: [ "podcastId": podcast._id, "podcastTitle": podcast.podcastTitle, "tags": podcast.tagsAsString, - "categories": podcast.categoriesAsString + "categories": podcast.categoriesAsString, + "isLoggedIn": UserManager.sharedInstance.isCurrentUserLoggedIn() ] ) } + class func logFeedViewed() { + Answers.logCustomEvent(withName: "Feed_Viewed") + } + + + + class func logRelatedLinkViewedFromFeed(url: URL) { + Answers.logCustomEvent(withName: "RelatedLink_Viewed_From_Feed", customAttributes: + [ + "website": url.absoluteString, + "isLoggedIn": UserManager.sharedInstance.isCurrentUserLoggedIn() + ] + ) + } + class func logLogin(user: User) { Answers.logLogin(withMethod: "SEDaily_API", success: 1, customAttributes: [ - "username": user.email ?? "" - ] + "username": user.email ] ) } - + class func logRegister(user: User) { Answers.logSignUp(withMethod: "SEDaily_API", success: 1, customAttributes: [ - "username": user.email ?? "" - ] + "username": user.email ] ) } - + class func logFacebookLogin(user: User) { Answers.logLogin(withMethod: "Facebook", success: 1, customAttributes: [ - "username": user.email ?? "" + "username": user.email ] ) } } extension Tracker { - //MARK: errors - class func logLoginError(error: Error) { Answers.logLogin(withMethod: "SEDaily_API", success: 0, customAttributes: [ @@ -74,7 +87,7 @@ extension Tracker { ] ) } - + class func logLoginError(string: String) { Answers.logLogin(withMethod: "SEDaily_API", success: 0, customAttributes: [ @@ -82,7 +95,7 @@ extension Tracker { ] ) } - + class func logRegisterError(error: Error) { Answers.logSignUp(withMethod: "SEDaily_API", success: 0, customAttributes: [ @@ -90,7 +103,7 @@ extension Tracker { ] ) } - + class func logRegisterError(string: String) { Answers.logSignUp(withMethod: "SEDaily_API", success: 0, customAttributes: [ @@ -98,7 +111,7 @@ extension Tracker { ] ) } - + class func logFacebookLoginError(error: Error) { Answers.logLogin(withMethod: "Facebook_Login", success: 0, customAttributes: [ @@ -106,7 +119,7 @@ extension Tracker { ] ) } - + class func logGeneralError(error: Error) { Answers.logCustomEvent(withName: "Error_General", customAttributes: @@ -115,7 +128,7 @@ extension Tracker { ] ) } - + class func logGeneralError(string: String) { Answers.logCustomEvent(withName: "Error_General", customAttributes: diff --git a/SEDaily-IOS/AppDelegate.swift b/SEDaily-IOS/AppDelegate.swift index 3af6028..cc53ce5 100644 --- a/SEDaily-IOS/AppDelegate.swift +++ b/SEDaily-IOS/AppDelegate.swift @@ -12,67 +12,84 @@ let log = SwiftyBeaver.self import IQKeyboardManagerSwift import Fabric import Crashlytics +import Kingfisher +//import Stripe +import Firebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - - var window: UIWindow? - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - - Fabric.with([Crashlytics.self]) - setupSwiftyBeaver() - setupIQKeyboard() - setupFirstScreen() - - return true - } - - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - - + + var window: UIWindow? + var coordinator: MainFlowCoordinator? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { + // STPPaymentConfiguration.shared().publishableKey = "pk_live_Cfttsv5i5ZG5IBfrmllzNoSA" + + Fabric.with([Crashlytics.self]) + setupSwiftyBeaver() + setupIQKeyboard() + setupFirstScreen() + + // Max size for Kingfisher ImageCache + let maxByteSize: UInt = 50 * 1024 * 1024 + ImageCache.default.maxDiskCacheSize = maxByteSize + ImageCache.default.maxMemoryCost = maxByteSize + + #if DEBUG + print("FIREBASE OFF, DEBUG / DEV MODE-------") + #else + FirebaseApp.configure() + #endif + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + } extension AppDelegate { - // MARK: Setup - func setupSwiftyBeaver() { - // Setup swifty beaver - let console = ConsoleDestination() - log.addDestination(console) // add to SwiftyBeaver - } - - func setupIQKeyboard() { - IQKeyboardManager.sharedManager().enable = true - } - - func setupFirstScreen() { - window = UIWindow(frame: UIScreen.main.bounds) - let rootVC = ContainerViewController() - rootVC.view.backgroundColor = .white - window!.rootViewController = rootVC - window!.makeKeyAndVisible() - } -} + // MARK: Setup + func setupSwiftyBeaver() { + // Setup swifty beaver + let console = ConsoleDestination() + log.addDestination(console) // add to SwiftyBeaver + } + + func setupIQKeyboard() { + IQKeyboardManager.sharedManager().enable = true + } + + func setupFirstScreen() { + window = UIWindow(frame: UIScreen.main.bounds) + let rootVC = RootViewController() + window!.rootViewController = rootVC + window!.makeKeyAndVisible() + + // Override point for customization after application launch. + if let initialViewController = window!.rootViewController as? RootViewController { + coordinator = MainFlowCoordinator(mainViewController: initialViewController) + } + } +} diff --git a/SEDaily-IOS/AskForReview.swift b/SEDaily-IOS/AskForReview.swift new file mode 100644 index 0000000..3e03a30 --- /dev/null +++ b/SEDaily-IOS/AskForReview.swift @@ -0,0 +1,36 @@ +// +// AskForReview.swift +// SEDaily-IOS +// +// Created by Eduardo Saenz on 11/9/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import WaitForIt + +struct AskForReview: ScenarioProtocol { + static let completedReviewKey = "completedReview" + + static func config() { + /* + Trigger an event three times minimum before executing + Wait at least one day after the first event to execute + Execute at most three times with a check if the review has been completed + */ + minEventsRequired = 3 + minSecondsSinceFirstEvent = 86_400 // seconds in one day + minSecondsSinceLastEvent = 86_400 // seconds in one day + maxExecutionsPermitted = 3 + customConditions = { + let defaults = UserDefaults.standard + return !(defaults.object(forKey: completedReviewKey) != nil && + defaults.bool(forKey: completedReviewKey)) + } + } + + static func setReviewed() { + let defaults = UserDefaults.standard + defaults.set(true, forKey: AskForReview.completedReviewKey) + } +} diff --git a/SEDaily-IOS/Asset.swift b/SEDaily-IOS/Asset.swift new file mode 100644 index 0000000..2f8050a --- /dev/null +++ b/SEDaily-IOS/Asset.swift @@ -0,0 +1,49 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt for this sample’s licensing information + + Abstract: + `Asset` is a wrapper struct around an `AVURLAsset` and its asset name. + */ + +import Foundation +import AVFoundation + +public class Asset { + // MARK: Types + static let nameKey = "AssetName" + + // MARK: Properties + + /// The name of the asset to present in the application. + public var assetName: String = "" + + // Custom artwork + public var artworkURL: URL? = nil + + /// The `AVURLAsset` corresponding to an asset in either the application bundle or on the Internet. + public var urlAsset: AVURLAsset + + public var savedTime: Float = 0 // This is in seconds + + // @TODO: Idk if CMTime is the right thing to use + public var savedCMTime: CMTime { + get { + return CMTimeMake(Int64(savedTime), 1) + } + } + + public init(assetName: String, url: URL, artworkURL: URL? = nil, savedTime: Float = 0) { + self.assetName = assetName + let avURLAsset = AVURLAsset(url: url) + self.urlAsset = avURLAsset + self.artworkURL = artworkURL + self.savedTime = savedTime + } +} + +extension Asset: Equatable { + public static func == (lhs: Asset, rhs: Asset) -> Bool { + return lhs.assetName == rhs.assetName && lhs.urlAsset == lhs.urlAsset + } +} diff --git a/SEDaily-IOS/AssetPlayer.swift b/SEDaily-IOS/AssetPlayer.swift new file mode 100644 index 0000000..b409a05 --- /dev/null +++ b/SEDaily-IOS/AssetPlayer.swift @@ -0,0 +1,811 @@ +// +// AssetPlayer.swift +// KoalaTeaPlayer +// +// Created by Craig Holliday on 9/26/17. +// +import Foundation +import AVFoundation +import MediaPlayer + +public protocol AssetPlayerDelegate: NSObjectProtocol { + // Setuo + func currentAssetDidChange(_ player: AssetPlayer) + func playerIsSetup(_ player: AssetPlayer) + + // Playback + func playerPlaybackStateDidChange(_ player: AssetPlayer) + func playerCurrentTimeDidChange(_ player: AssetPlayer) + func playerPlaybackDidEnd(_ player: AssetPlayer) + + // Buffering + func playerIsLikelyToKeepUp(_ player: AssetPlayer) + // This is the time in seconds that the video has been buffered. + // If implementing a UIProgressView, user this value / player.maximumDuration to set progress. + func playerBufferTimeDidChange(_ player: AssetPlayer) +} + +public enum AssetPlayerPlaybackState: Equatable { + /// Returns a Boolean value indicating whether two values are equal. + /// + /// Equality is the inverse of inequality. For any values `a` and `b`, + /// `a == b` implies that `a != b` is `false`. + /// + /// - Parameters: + /// - lhs: A value to compare. + /// - rhs: Another value to compare. + public static func ==(lhs: AssetPlayerPlaybackState, rhs: AssetPlayerPlaybackState) -> Bool { + switch (lhs, rhs) { + case (.setup(let lKey), .setup(let rKey)): + return lKey == rKey + case (.playing, .playing): + return true + case (.paused, .paused): + return true + case (.interrupted, .interrupted): + return true + case (.failed, .failed): + return true + case (.buffering, .buffering): + return true + case (.stopped, .stopped): + return true + default: + return false + } + } + + case setup(asset: Asset?) + case playing, paused, interrupted, failed, buffering, stopped +} + +/* + KVO context used to differentiate KVO callbacks for this class versus other + classes in its class hierarchy. + */ +private var AssetPlayerKVOContext = 0 + +public class AssetPlayer: NSObject { + // MARK: Properties + /// Player delegate. + public weak var playerDelegate: AssetPlayerDelegate? + + // Attempt load and test these asset keys before playing. + static let assetKeysRequiredToPlay = [ + "playable", + "hasProtectedContent" + ] + + @objc let player = AVPlayer() + + public var isPlayingLocalVideo = false + public var startTimeForLoop: Double = 0 + + // Mark: Time Properties + public var currentTime: Double = 0 { + didSet { + //@TODO: may not need playback did end here + guard currentTime < duration else { + self.playerDelegate?.playerPlaybackDidEnd(self) + return + } + self.playerDelegate?.playerCurrentTimeDidChange(self) + } + } + public var bufferedTime: Float = 0 { + didSet { + self.playerDelegate?.playerBufferTimeDidChange(self) + } + } + + public var timeElapsedText: String = "" + public var durationText: String = "" + + public var timeLeftText: String { + get { + let timeLeft = duration - currentTime + return self.createTimeString(time: Float(timeLeft)) + } + } + + public var maxSecondValue: Float = 0 + + public var duration: Double { + guard let currentItem = player.currentItem else { return 0.0 } + + return CMTimeGetSeconds(currentItem.duration) + } + + public var rate: Float = 1.0 { + willSet { + guard newValue != self.rate else { return } + } + didSet { + player.rate = rate + self.checkAudioRateAndSetTimePitchAlgorithm() + } + } + + public var shouldLoop: Bool = false + + private var currentAVAudioTimePitchAlgorithm: AVAudioTimePitchAlgorithm = .timeDomain { + willSet { + guard newValue != self.currentAVAudioTimePitchAlgorithm else { return } + } + didSet { + self.playerItem?.audioTimePitchAlgorithm = self.currentAVAudioTimePitchAlgorithm + } + } + + private func checkAudioRateAndSetTimePitchAlgorithm() { + guard self.rate <= 2.0 else { + self.currentAVAudioTimePitchAlgorithm = .spectral + return + } + self.currentAVAudioTimePitchAlgorithm = .timeDomain + } + + public var asset: Asset? { + didSet { + guard let newAsset = self.asset else { return } + + asynchronouslyLoadURLAsset(newAsset) + } + } + + private var playerLayer: AVPlayerLayer? { + return playerView?.playerLayer + } + + /* + A token obtained from calling `player`'s `addPeriodicTimeObserverForInterval(_:queue:usingBlock:)` + method. + */ + private var timeObserverToken: Any? + + // @TODO: Do we need to remove observers + public var playerItem: AVPlayerItem? = nil { + willSet { + if playerItem != nil { + self.removePlayerItemObservers() + } + } + didSet { + if playerItem != nil { + self.addPlayerItemObservers() + } + /* + If needed, configure player item here before associating it with a player. + (example: adding outputs, setting text style rules, selecting media options) + */ + player.replaceCurrentItem(with: self.playerItem) + } + } + + public var playerView: PlayerView? = PlayerView(frame: .zero) + + /// The instance of `MPNowPlayingInfoCenter` that is used for updating metadata for the currently playing `Asset`. + fileprivate let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + + /// A Bool for tracking if playback should be resumed after an interruption. See README.md for more information. + fileprivate var shouldResumePlaybackAfterInterruption = true + + // The state that the internal `AVPlayer` is in. + public var state: AssetPlayerPlaybackState? = nil { + didSet { + if state != oldValue { + self.playerDelegate?.playerPlaybackStateDidChange(self) + self.handleStateChange() + } + } + } + + func handleStateChange() { + guard let state = self.state else { return } + switch state { + case .setup(let asset): + self.setupAVAudioSession() + self.asset = asset + break + case .playing: + guard self.asset != nil else { return } + + // if shouldResumePlaybackAfterInterruption == false { + // shouldResumePlaybackAfterInterruption = true + // return + // } + // + //@TODO: Check if there are any issues with "playImmediately" + if #available(iOS 10.0, *) { + player.playImmediately(atRate: self.rate) + } else { + // Fallback on earlier versions + player.play() + player.rate = self.rate + } + // self.player.play() + break + case .paused: + guard asset != nil else { return } + + if state == .interrupted { + // shouldResumePlaybackAfterInterruption = false + return + } + + self.player.pause() + break + case .interrupted: + break + case .failed: + break + case .buffering: + guard asset != nil else { return } + self.player.pause() + break + case .stopped: + guard asset != nil else { return } + + if shouldLoop { + self.seekTo(interval: startTimeForLoop) + self.play() + return + } + + asset = nil + playerItem = nil + self.player.replaceCurrentItem(with: nil) + // @TODO: just deinit? + break + } + } + + // Method to set state so this can be called in init + public func setState(to state: AssetPlayerPlaybackState) { + self.state = state + } + + // MARK: - Life Cycle + public init(asset: Asset?) { + super.init() + + /* + Update the UI when these player properties change. + Use the context parameter to distinguish KVO for our particular observers + and not those destined for a subclass that also happens to be observing + these properties. + */ + addObserver(self, forKeyPath: #keyPath(AssetPlayer.player.currentItem.duration), options: [.new, .initial], context: &AssetPlayerKVOContext) + addObserver(self, forKeyPath: #keyPath(AssetPlayer.player.rate), options: [.new, .initial], context: &AssetPlayerKVOContext) + addObserver(self, forKeyPath: #keyPath(AssetPlayer.player.currentItem.status), options: [.new, .initial], context: &AssetPlayerKVOContext) + + playerView?.playerLayer.player = player + + self.setState(to: .setup(asset: asset)) + + // Make sure we don't have a strong reference cycle by only capturing self as weak. + let interval = CMTimeMake(1, 1) + timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main) { [unowned self] time in + let timeElapsed = Float(CMTimeGetSeconds(time)) + + self.currentTime = Double(timeElapsed) + self.timeElapsedText = self.createTimeString(time: timeElapsed) + } + } + + deinit { + print("DEINITING") + if let timeObserverToken = timeObserverToken { + player.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + + player.pause() + + removeObserver(self, forKeyPath: #keyPath(AssetPlayer.player.currentItem.duration), context: &AssetPlayerKVOContext) + removeObserver(self, forKeyPath: #keyPath(AssetPlayer.player.rate), context: &AssetPlayerKVOContext) + removeObserver(self, forKeyPath: #keyPath(AssetPlayer.player.currentItem.status), context: &AssetPlayerKVOContext) + + self.removeAVAudioSessionObservers() + //@TODO: Simplify removing observers + if playerItem != nil { + self.removePlayerItemObservers() + } + } + + // MARK: - Asset Loading + func asynchronouslyLoadURLAsset(_ newAsset: Asset) { + /* + Using AVAsset now runs the risk of blocking the current thread (the + main UI thread) whilst I/O happens to populate the properties. It's + prudent to defer our work until the properties we need have been loaded. + */ + newAsset.urlAsset.loadValuesAsynchronously(forKeys: AssetPlayer.assetKeysRequiredToPlay) { + /* + The asset invokes its completion handler on an arbitrary queue. + To avoid multiple threads using our internal state at the same time + we'll elect to use the main thread at all times, let's dispatch + our handler to the main queue. + */ + DispatchQueue.main.async { + /* + `self.asset` has already changed! No point continuing because + another `newAsset` will come along in a moment. + */ + guard newAsset == self.asset else { return } + + /* + Test whether the values of each of the keys we need have been + successfully loaded. + */ + for key in AssetPlayer.assetKeysRequiredToPlay { + var error: NSError? + + if newAsset.urlAsset.statusOfValue(forKey: key, error: &error) == .failed { + let stringFormat = NSLocalizedString("error.asset_key_%@_failed.description", comment: "Can't use this AVAsset because one of it's keys failed to load") + + let message = String.localizedStringWithFormat(stringFormat, key) + + self.handleErrorWithMessage(message, error: error) + + return + } + } + + // We can't play this asset. + if !newAsset.urlAsset.isPlayable || newAsset.urlAsset.hasProtectedContent { + let message = NSLocalizedString("error.asset_not_playable.description", comment: "Can't use this AVAsset because it isn't playable or has protected content") + + self.handleErrorWithMessage(message) + + return + } + + /* + We can play this asset. Create a new `AVPlayerItem` and make + it our player's current item. + */ + self.playerItem = AVPlayerItem(asset: newAsset.urlAsset) + // Set time pitch algorithm to spectral allows the audio to speed up to 3.0 + if newAsset.savedTime != 0 { + self.seekTo(newAsset.savedCMTime) + } + + self.playerDelegate?.currentAssetDidChange(self) + } + } + } + + // MARK: - KVO Observation + // Update our UI when player or `player.currentItem` changes. + override public func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + // Make sure the this KVO callback was intended for this view controller. + guard context == &AssetPlayerKVOContext else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + + if keyPath == #keyPath(AssetPlayer.player.currentItem.duration) { + // Update timeSlider and enable/disable controls when duration > 0.0 + /* + Handle `NSNull` value for `NSKeyValueChangeNewKey`, i.e. when + `player.currentItem` is nil. + */ + let newDuration: CMTime + if let newDurationAsValue = change?[NSKeyValueChangeKey.newKey] as? NSValue { + newDuration = newDurationAsValue.timeValue + } + else { + newDuration = kCMTimeZero + } + + let hasValidDuration = newDuration.isNumeric && newDuration.value != 0 + let newDurationSeconds = hasValidDuration ? CMTimeGetSeconds(newDuration) : 0.0 + let currentTime = hasValidDuration ? Float(CMTimeGetSeconds(player.currentTime())) : 0.0 + + self.maxSecondValue = Float(newDurationSeconds) + self.timeElapsedText = createTimeString(time: currentTime) + self.durationText = createTimeString(time: Float(newDurationSeconds)) + + self.playerDelegate?.playerIsSetup(self) + self.updateGeneralMetadata() + } + else if keyPath == #keyPath(AssetPlayer.player.rate) { + // Update `playPauseButton` image. + // let newRate = (change?[NSKeyValueChangeKey.newKey] as! NSNumber).doubleValue + // + // let buttonImageName = newRate == 1.0 ? "PauseButton" : "PlayButton" + // + // let buttonImage = UIImage(named: buttonImageName) + // + // playPauseButton.setImage(buttonImage, for: UIControlState()) + // @TODO: What to do with player rate? + // Update Metadata + // @TODO: How many times here? + self.updatePlaybackRateMetadata() + } + else if keyPath == #keyPath(AssetPlayer.player.currentItem.status) { + // Display an error if status becomes `.Failed`. + /* + Handle `NSNull` value for `NSKeyValueChangeNewKey`, i.e. when + `player.currentItem` is nil. + */ + let newStatus: AVPlayerItemStatus + + if let newStatusAsNumber = change?[NSKeyValueChangeKey.newKey] as? NSNumber { + newStatus = AVPlayerItemStatus(rawValue: newStatusAsNumber.intValue)! + } + else { + newStatus = .unknown + } + + if newStatus == .failed { + handleErrorWithMessage(player.currentItem?.error?.localizedDescription, error:player.currentItem?.error) + } + } + // All Buffer observer values + else if keyPath == #keyPath(AVPlayerItem.isPlaybackBufferEmpty) { + // PlayerEmptyBufferKey + if let item = self.playerItem { + if item.isPlaybackBufferEmpty { + guard state != .paused else { return } + + // No need to set buffering if we're playing locally + guard !isPlayingLocalVideo else { return } + self.state = .buffering + } + } + } + else if keyPath == #keyPath(AVPlayerItem.isPlaybackLikelyToKeepUp) { + // PlayerKeepUpKey + if let item = self.playerItem { + if item.isPlaybackLikelyToKeepUp { + if self.state == .buffering || self.state == .playing { + //@TODO: Check if this guard is breaking the rest of this section + guard state != .paused else { return } + self.playerDelegate?.playerIsLikelyToKeepUp(self) + self.state = .playing + return + } + } + } + } + else if keyPath == #keyPath(AVPlayerItem.loadedTimeRanges) { + //@TODO: This gets checked a lot + // PlayerLoadedTimeRangesKey + if let item = self.playerItem { + let timeRanges = item.loadedTimeRanges + if let timeRange = timeRanges.first?.timeRangeValue { + let bufferedTime: Float = Float(CMTimeGetSeconds(CMTimeAdd(timeRange.start, timeRange.duration))) + // Smart Value check for buffered time to switch to playing state + // or switch to buffering state + let smartValue = (bufferedTime - Float(self.currentTime)) > 5 || bufferedTime.rounded() == Float(self.currentTime.rounded()) + //@TODO: Clean this up + switch smartValue { + case true: + if self.state != .buffering, self.state != .paused, self.state != .playing { + self.state = .playing + } + break + case false: + if self.state != .buffering && self.state != .paused { + // No need to set buffering if we're playing locally + guard !isPlayingLocalVideo else { return } + self.state = .buffering + } + break + } + self.bufferedTime = Float(bufferedTime) + } else { + // self.playFromCurrentTime() + } + } + } + } + + // Trigger KVO for anyone observing our properties affected by player and player.currentItem + override public class func keyPathsForValuesAffectingValue(forKey key: String) -> Set { + let affectedKeyPathsMappingByKey: [String: Set] = [ + "duration": [#keyPath(AssetPlayer.player.currentItem.duration)], + "rate": [#keyPath(AssetPlayer.player.rate)] + ] + + return affectedKeyPathsMappingByKey[key] ?? super.keyPathsForValuesAffectingValue(forKey: key) + } + + // MARK: Notification Observing Methods + @objc func handleAVPlayerItemDidPlayToEndTimeNotification(notification: Notification) { + self.playerDelegate?.playerPlaybackDidEnd(self) + self.state = .stopped + } + + // MARK: - Error Handling + func handleErrorWithMessage(_ message: String?, error: Error? = nil) { + NSLog("Error occured with message: \(String(describing: message)), error: \(String(describing: error)).") + + let alertTitle = NSLocalizedString("alert.error.title", comment: "Alert title for errors") + let defaultAlertMessage = NSLocalizedString("error.default.description", comment: "Default error message when no NSError provided") + + let alert = UIAlertController(title: alertTitle, message: message == nil ? defaultAlertMessage : message, preferredStyle: UIAlertControllerStyle.alert) + + let alertActionTitle = NSLocalizedString("alert.error.actions.OK", comment: "OK on error alert") + + let alertAction = UIAlertAction(title: alertActionTitle, style: .default, handler: nil) + + alert.addAction(alertAction) + + // present(alert, animated: true, completion: nil) + } + + // MARK: Convenience + /* + A formatter for individual date components used to provide an appropriate + value for the `startTimeLabel` and `durationLabel`. + */ + let timeRemainingFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = [.minute, .second] + + return formatter + }() + + func createTimeString(time: Float) -> String { + let components = NSDateComponents() + components.second = Int(max(0.0, time)) + + return timeRemainingFormatter.string(from: components as DateComponents)! + } +} + +// MARK: Playback Control Methods. +extension AssetPlayer { + public func play() { + self.state = .playing + } + + public func pause() { + self.state = .paused + } + + public func togglePlayPause() { + guard asset != nil else { return } + + if self.player.rate != 1.0 { + // Not playing forward, so play. + self.state = .playing + } + else { + // Playing, so pause. + self.state = .paused + } + } + + public func stop() { + self.state = .stopped + } + + //@TODO: Do we need other notifications for RemoteCommand + /// Notification that is posted when the `nextTrack()` is called. + fileprivate static let nextTrackNotification = Notification.Name("nextTrackNotification") + + /// Notification that is posted when the `previousTrack()` is called. + fileprivate static let previousTrackNotification = Notification.Name("previousTrackNotification") + + func nextTrack() { + guard asset != nil else { return } + + NotificationCenter.default.post(name: AssetPlayer.nextTrackNotification, object: nil, userInfo: [Asset.nameKey: asset?.assetName ?? ""]) + } + + func previousTrack() { + guard asset != nil else { return } + + NotificationCenter.default.post(name: AssetPlayer.previousTrackNotification, object: nil, userInfo: [Asset.nameKey: asset?.assetName ?? ""]) + } + + public func skipForward(_ interval: TimeInterval) { + guard asset != nil else { return } + + let currentTime = self.player.currentTime() + let offset = CMTimeMakeWithSeconds(interval, 1) + + let newTime = CMTimeAdd(currentTime, offset) + self.seekTo(newTime) + } + + public func skipBackward(_ interval: TimeInterval) { + guard asset != nil else { return } + + let currentTime = self.player.currentTime() + let offset = CMTimeMakeWithSeconds(interval, 1) + + let newTime = CMTimeSubtract(currentTime, offset) + self.seekTo(newTime) + } + + public func seekTo(_ newPosition: CMTime) { + guard asset != nil else { return } + self.player.seek(to: newPosition, toleranceBefore: kCMTimeZero, toleranceAfter: kCMTimeZero, completionHandler: { (_) in + self.updatePlaybackRateMetadata() + // self.checkPaused() + }) + } + + public func seekTo(interval: TimeInterval) { + guard asset != nil else { return } + let newPosition = CMTimeMakeWithSeconds(interval, 600) + self.player.seek(to: newPosition, toleranceBefore: kCMTimeZero, toleranceAfter: kCMTimeZero, completionHandler: { (_) in + self.updatePlaybackRateMetadata() + // self.checkPaused() + }) + } + + public func changePlayerPlaybackRate(to newRate: Float) { + guard asset != nil else { return } + + DispatchQueue.main.async { + self.rate = newRate + } + } + + public func beginRewind() { + guard asset != nil else { return } + + rate = max(player.rate - 2.0, -2.0) + } + + public func beginFastForward() { + guard asset != nil else { return } + + rate = min(player.rate + 2.0, 2.0) + } + + public func endRewindFastForward() { + guard asset != nil else { return } + + rate = 1.0 + } +} + +extension AssetPlayer { + // Player buffer observers + internal func addPlayerItemObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(self.handleAVPlayerItemDidPlayToEndTimeNotification(notification:)), name: .AVPlayerItemDidPlayToEndTime, object: playerItem) + + playerItem?.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.isPlaybackBufferEmpty), options: ([.new, .old]), context: &AssetPlayerKVOContext) + playerItem?.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.isPlaybackLikelyToKeepUp), options: ([.new, .old]), context: &AssetPlayerKVOContext) + playerItem?.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges), options: ([.new, .old]), context: &AssetPlayerKVOContext) + } + + internal func removePlayerItemObservers() { + NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem) + + playerItem?.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.isPlaybackBufferEmpty), context: &AssetPlayerKVOContext) + playerItem?.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.isPlaybackLikelyToKeepUp), context: &AssetPlayerKVOContext) + playerItem?.removeObserver(self, forKeyPath: #keyPath(AVPlayerItem.loadedTimeRanges), context: &AssetPlayerKVOContext) + } +} + +// MARK: MPNowPlayingInforCenter Management Methods +extension AssetPlayer { + func updateGeneralMetadata() { + guard self.player.currentItem != nil, let urlAsset = self.player.currentItem?.asset else { + nowPlayingInfoCenter.nowPlayingInfo = nil + + return + } + + var nowPlayingInfo = nowPlayingInfoCenter.nowPlayingInfo ?? [String: Any]() + + let title = AVMetadataItem.metadataItems(from: urlAsset.commonMetadata, withKey: AVMetadataKey.commonKeyTitle, keySpace: AVMetadataKeySpace.common).first?.value as? String ?? asset?.assetName + let album = AVMetadataItem.metadataItems(from: urlAsset.commonMetadata, withKey: AVMetadataKey.commonKeyAlbumName, keySpace: AVMetadataKeySpace.common).first?.value as? String ?? "" + var artworkData = AVMetadataItem.metadataItems(from: urlAsset.commonMetadata, withKey: AVMetadataKey.commonKeyArtwork, keySpace: AVMetadataKeySpace.common).first?.value as? Data ?? Data() + if let url = asset?.artworkURL { + if let data = try? Data(contentsOf: url) { + artworkData = data + } + } + + let image = UIImage(data: artworkData) ?? UIImage() + var artwork = MPMediaItemArtwork(image: image) + if #available(iOS 10.0, *) { + artwork = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { (_) -> UIImage in + return image + }) + } + + nowPlayingInfo[MPMediaItemPropertyTitle] = title + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + + nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo + } + + func updatePlaybackRateMetadata() { + guard self.player.currentItem != nil else { + nowPlayingInfoCenter.nowPlayingInfo = nil + + return + } + + var nowPlayingInfo = nowPlayingInfoCenter.nowPlayingInfo ?? [String: Any]() + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = CMTimeGetSeconds(self.player.currentItem!.currentTime()) + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = self.player.rate + nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = self.player.rate + + nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo + } +} + +// MARK: - AudioSession +extension AssetPlayer { + @objc func handleAudioSessionInterruption(notification: Notification) { + guard let userInfo = notification.userInfo, let typeInt = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let interruptionType = AVAudioSessionInterruptionType(rawValue: typeInt) else { return } + + switch interruptionType { + case .began: + self.state = .interrupted + case .ended: + do { + try AVAudioSession.sharedInstance().setActive(true, with: []) + + if shouldResumePlaybackAfterInterruption == false { + shouldResumePlaybackAfterInterruption = true + + return + } + + guard let optionsInt = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } + + let interruptionOptions = AVAudioSessionInterruptionOptions(rawValue: optionsInt) + + if interruptionOptions.contains(.shouldResume) { + play() + } + } + catch { + print("An Error occured activating the audio session while resuming from interruption: \(error)") + } + } + } + + func setupAVAudioSession() { + // Setup AVAudioSession to indicate to the system you how intend to play audio. + let audioSession = AVAudioSession.sharedInstance() + + self.addAVAudioSessionObservers() + + do { + if #available(iOS 10.0, *) { + try audioSession.setCategory(AVAudioSessionCategoryPlayback, mode: AVAudioSessionModeDefault) + } else { + // Fallback on earlier versions + try audioSession.setCategory(AVAudioSessionCategoryPlayback) + } + } + catch { + print("An error occured setting the audio session category: \(error)") + } + + // Set the AVAudioSession as active. This is required so that your application becomes the "Now Playing" app. + do { + try audioSession.setActive(true) + } + catch { + print("An Error occured activating the audio session: \(error)") + } + } + + func addAVAudioSessionObservers() { + // Add the notification observer needed to respond to audio interruptions. + NotificationCenter.default.addObserver(self, selector: #selector(self.handleAudioSessionInterruption(notification:)), name: .AVAudioSessionInterruption, object: AVAudioSession.sharedInstance()) + } + + func removeAVAudioSessionObservers() { + // Remove audio session interruption observer + NotificationCenter.default.removeObserver(self, name: .AVAudioSessionInterruption, object: AVAudioSession.sharedInstance()) + } +} diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down.png b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down.png new file mode 100644 index 0000000..2b96531 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@2x.png b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@2x.png new file mode 100644 index 0000000..290445f Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@3x.png b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@3x.png new file mode 100644 index 0000000..2313267 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Arrow-Down@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Contents.json similarity index 68% rename from SEDaily-IOS/Assets.xcassets/activity_feed.imageset/Contents.json rename to SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Contents.json index d9d33cd..aa5cc92 100644 --- a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/Contents.json +++ b/SEDaily-IOS/Assets.xcassets/Arrow-Down.imageset/Contents.json @@ -2,17 +2,17 @@ "images" : [ { "idiom" : "universal", - "filename" : "activity_feed.png", + "filename" : "Arrow-Down.png", "scale" : "1x" }, { "idiom" : "universal", - "filename" : "activity_feed@2x.png", + "filename" : "Arrow-Down@2x.png", "scale" : "2x" }, { "idiom" : "universal", - "filename" : "activity_feed@3x.png", + "filename" : "Arrow-Down@3x.png", "scale" : "3x" } ], diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up.png b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up.png new file mode 100644 index 0000000..d1a7480 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@2x.png b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@2x.png new file mode 100644 index 0000000..4bfb82e Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@3x.png b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@3x.png new file mode 100644 index 0000000..55fccf8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Arrow-Up@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Contents.json similarity index 60% rename from SEDaily-IOS/Assets.xcassets/Forward.imageset/Contents.json rename to SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Contents.json index 3f2589f..4bafa15 100644 --- a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Contents.json +++ b/SEDaily-IOS/Assets.xcassets/Arrow-Up.imageset/Contents.json @@ -2,25 +2,22 @@ "images" : [ { "idiom" : "universal", - "filename" : "Forward.png", + "filename" : "Arrow-Up.png", "scale" : "1x" }, { "idiom" : "universal", - "filename" : "Forward@2x.png", + "filename" : "Arrow-Up@2x.png", "scale" : "2x" }, { "idiom" : "universal", - "filename" : "Forward@3x.png", + "filename" : "Arrow-Up@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" } } \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward.png b/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward.png deleted file mode 100644 index bfdeef6..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@2x.png b/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@2x.png deleted file mode 100644 index a054c07..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@2x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@3x.png b/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@3x.png deleted file mode 100644 index aa86963..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Backward@3x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward.png b/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward.png deleted file mode 100644 index ebcef0e..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@2x.png b/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@2x.png deleted file mode 100644 index 2a69fb0..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@2x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@3x.png b/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@3x.png deleted file mode 100644 index fd3a0d7..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/Forward.imageset/Forward@3x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/Square.imageset/Contents.json similarity index 60% rename from SEDaily-IOS/Assets.xcassets/Backward.imageset/Contents.json rename to SEDaily-IOS/Assets.xcassets/Square.imageset/Contents.json index 578a84d..e805777 100644 --- a/SEDaily-IOS/Assets.xcassets/Backward.imageset/Contents.json +++ b/SEDaily-IOS/Assets.xcassets/Square.imageset/Contents.json @@ -2,25 +2,22 @@ "images" : [ { "idiom" : "universal", - "filename" : "Backward.png", + "filename" : "Square.png", "scale" : "1x" }, { "idiom" : "universal", - "filename" : "Backward@2x.png", + "filename" : "Square@2x.png", "scale" : "2x" }, { "idiom" : "universal", - "filename" : "Backward@3x.png", + "filename" : "Square@3x.png", "scale" : "3x" } ], "info" : { "version" : 1, "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" } } \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/Square.imageset/Square.png b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square.png new file mode 100644 index 0000000..48a9342 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@2x.png b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@2x.png new file mode 100644 index 0000000..8569bf8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@3x.png b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@3x.png new file mode 100644 index 0000000..5bcf11a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Square.imageset/Square@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Contents.json new file mode 100644 index 0000000..c50f73a --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Triangle.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "Triangle@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "Triangle@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle.png b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle.png new file mode 100644 index 0000000..9638189 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@2x.png b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@2x.png new file mode 100644 index 0000000..4c8b054 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@3x.png b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@3x.png new file mode 100644 index 0000000..ffb5f4f Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/Triangle.imageset/Triangle@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed.png b/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed.png deleted file mode 100644 index 0a76058..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@2x.png b/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@2x.png deleted file mode 100644 index 9184164..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@2x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@3x.png b/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@3x.png deleted file mode 100644 index 316fb82..0000000 Binary files a/SEDaily-IOS/Assets.xcassets/activity_feed.imageset/activity_feed@3x.png and /dev/null differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/Contents.json new file mode 100644 index 0000000..eb049e3 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bookmark@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "bookmark@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "bookmark@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@1x.png b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@1x.png new file mode 100644 index 0000000..c8749af Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@2x.png b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@2x.png new file mode 100644 index 0000000..10f0ef4 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@3x.png b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@3x.png new file mode 100644 index 0000000..1337a5a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark.imageset/bookmark@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/Contents.json new file mode 100644 index 0000000..e5b235d --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bookmark_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "bookmark_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "bookmark_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@1x.png b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@1x.png new file mode 100644 index 0000000..bc44c3e Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@2x.png b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@2x.png new file mode 100644 index 0000000..69f3d36 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@3x.png b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@3x.png new file mode 100644 index 0000000..a0de4c4 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/bookmark_outline.imageset/bookmark_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/comment.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/comment.imageset/Contents.json new file mode 100644 index 0000000..94d90ef --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/comment.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "comment@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "comment@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "comment@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@1x.png b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@1x.png new file mode 100644 index 0000000..bc314a2 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@2x.png b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@2x.png new file mode 100644 index 0000000..01b33fb Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@3x.png b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@3x.png new file mode 100644 index 0000000..85e8d7b Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/comment.imageset/comment@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/download.imageset/Contents.json new file mode 100644 index 0000000..f330e9e --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/download.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "download@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "download@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "download@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/download.imageset/download@1x.png b/SEDaily-IOS/Assets.xcassets/download.imageset/download@1x.png new file mode 100644 index 0000000..2bdc710 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download.imageset/download@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download.imageset/download@2x.png b/SEDaily-IOS/Assets.xcassets/download.imageset/download@2x.png new file mode 100644 index 0000000..393030b Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download.imageset/download@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download.imageset/download@3x.png b/SEDaily-IOS/Assets.xcassets/download.imageset/download@3x.png new file mode 100644 index 0000000..a562331 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download.imageset/download@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/Contents.json new file mode 100644 index 0000000..137c41b --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "download_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "download_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "download_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@1x.png b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@1x.png new file mode 100644 index 0000000..d12f3c3 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@2x.png b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@2x.png new file mode 100644 index 0000000..f6e32a9 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@3x.png b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@3x.png new file mode 100644 index 0000000..cf1be65 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_outline.imageset/download_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/Contents.json new file mode 100644 index 0000000..7918ca7 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "download_panel@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "download_panel@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "download_panel@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@1x.png b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@1x.png new file mode 100644 index 0000000..12b1ce8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@2x.png b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@2x.png new file mode 100644 index 0000000..ebdbb2c Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@3x.png b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@3x.png new file mode 100644 index 0000000..94376d6 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel.imageset/download_panel@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/Contents.json new file mode 100644 index 0000000..da65ad7 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "download_panel_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "download_panel_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "download_panel_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@1x.png b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@1x.png new file mode 100644 index 0000000..ea17f73 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@2x.png b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@2x.png new file mode 100644 index 0000000..06253f6 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@3x.png b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@3x.png new file mode 100644 index 0000000..54617eb Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/download_panel_outline.imageset/download_panel_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/Contents.json new file mode 100644 index 0000000..4d16cd6 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "forward_audio.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "forward_audio@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "forward_audio@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio.png b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio.png new file mode 100644 index 0000000..5b2dc1a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio.png differ diff --git a/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@2x.png b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@2x.png new file mode 100644 index 0000000..e4c13cc Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@3x.png b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@3x.png new file mode 100644 index 0000000..8fbbba8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/forward_audio.imageset/forward_audio@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/info.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/info.imageset/Contents.json new file mode 100644 index 0000000..c7d3dae --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/info.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "info.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "info@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "info@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/info.imageset/info.png b/SEDaily-IOS/Assets.xcassets/info.imageset/info.png new file mode 100644 index 0000000..97b93b6 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/info.imageset/info.png differ diff --git a/SEDaily-IOS/Assets.xcassets/info.imageset/info@2x.png b/SEDaily-IOS/Assets.xcassets/info.imageset/info@2x.png new file mode 100644 index 0000000..a4cb418 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/info.imageset/info@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/info.imageset/info@3x.png b/SEDaily-IOS/Assets.xcassets/info.imageset/info@3x.png new file mode 100644 index 0000000..9323237 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/info.imageset/info@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/latest.imageset/Contents.json new file mode 100644 index 0000000..87abbdb --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/latest.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "latest@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "latest@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "latest@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@1x.png b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@1x.png new file mode 100644 index 0000000..c6827e0 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@2x.png b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@2x.png new file mode 100644 index 0000000..41720c2 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@3x.png b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@3x.png new file mode 100644 index 0000000..14ba81c Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest.imageset/latest@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/Contents.json new file mode 100644 index 0000000..001840e --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "latest_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "latest_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "latest_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@1x.png b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@1x.png new file mode 100644 index 0000000..28f1701 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@2x.png b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@2x.png new file mode 100644 index 0000000..e7f9127 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@3x.png b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@3x.png new file mode 100644 index 0000000..7215f2f Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/latest_outline.imageset/latest_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/like.imageset/Contents.json new file mode 100644 index 0000000..73fbd20 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/like.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "like@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "like@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "like@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/like.imageset/like@1x.png b/SEDaily-IOS/Assets.xcassets/like.imageset/like@1x.png new file mode 100644 index 0000000..542f877 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like.imageset/like@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like.imageset/like@2x.png b/SEDaily-IOS/Assets.xcassets/like.imageset/like@2x.png new file mode 100644 index 0000000..575e034 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like.imageset/like@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like.imageset/like@3x.png b/SEDaily-IOS/Assets.xcassets/like.imageset/like@3x.png new file mode 100644 index 0000000..6028ce8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like.imageset/like@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/Contents.json new file mode 100644 index 0000000..6dc2db0 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "like_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "like_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "like_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@1x.png b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@1x.png new file mode 100644 index 0000000..4d32b40 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@2x.png b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@2x.png new file mode 100644 index 0000000..ee1665f Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@3x.png b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@3x.png new file mode 100644 index 0000000..7aea083 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/like_outline.imageset/like_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/link.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/link.imageset/Contents.json new file mode 100644 index 0000000..76b6263 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/link.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "link@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "link@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "link@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/link.imageset/link@1x.png b/SEDaily-IOS/Assets.xcassets/link.imageset/link@1x.png new file mode 100644 index 0000000..709edb7 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/link.imageset/link@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/link.imageset/link@2x.png b/SEDaily-IOS/Assets.xcassets/link.imageset/link@2x.png new file mode 100644 index 0000000..159d09c Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/link.imageset/link@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/link.imageset/link@3x.png b/SEDaily-IOS/Assets.xcassets/link.imageset/link@3x.png new file mode 100644 index 0000000..b543ab1 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/link.imageset/link@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/Contents.json new file mode 100644 index 0000000..666f137 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "logo_subtitle.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "logo_subtitle@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "logo_subtitle@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle.png b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle.png new file mode 100644 index 0000000..c7752eb Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle.png differ diff --git a/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@2x.png b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@2x.png new file mode 100644 index 0000000..f915a71 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@3x.png b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@3x.png new file mode 100644 index 0000000..99d3c91 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/logo_subtitle.imageset/logo_subtitle@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/Contents.json new file mode 100644 index 0000000..912ddda --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "menu_hamburger.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "menu_hamburger@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "menu_hamburger@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger.png b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger.png new file mode 100644 index 0000000..4a6540a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger.png differ diff --git a/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@2x.png b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@2x.png new file mode 100644 index 0000000..68ae693 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@3x.png b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@3x.png new file mode 100644 index 0000000..907f781 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/menu_hamburger.imageset/menu_hamburger@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause-big.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/Contents.json new file mode 100644 index 0000000..a887513 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "pause-big.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "pause-big@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "pause-big@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big.png b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big.png new file mode 100644 index 0000000..ec93363 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@2x.png b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@2x.png new file mode 100644 index 0000000..f12297d Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@3x.png b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@3x.png new file mode 100644 index 0000000..233718b Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause-big.imageset/pause-big@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/pause.imageset/Contents.json new file mode 100644 index 0000000..ecf2c0c --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/pause.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "pause.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "pause@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "pause@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/pause.imageset/pause.png b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause.png new file mode 100644 index 0000000..d1780dc Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@2x.png b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@2x.png new file mode 100644 index 0000000..a1a3398 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@3x.png b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@3x.png new file mode 100644 index 0000000..84b085b Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause.imageset/pause@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/Contents.json new file mode 100644 index 0000000..dbc6f58 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "pause_audio.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "pause_audio@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "pause_audio@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio.png b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio.png new file mode 100644 index 0000000..4e3c8ee Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@2x.png b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@2x.png new file mode 100644 index 0000000..7f5b15e Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@3x.png b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@3x.png new file mode 100644 index 0000000..a9496be Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_audio.imageset/pause_audio@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_white.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/Contents.json new file mode 100644 index 0000000..1ca3fc4 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "pause_white.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "pause_white@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "pause_white@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white.png b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white.png new file mode 100644 index 0000000..a8ce8d8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@2x.png b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@2x.png new file mode 100644 index 0000000..3cf54fc Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@3x.png b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@3x.png new file mode 100644 index 0000000..153d925 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/pause_white.imageset/pause_white@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/person.imageset/Contents.json new file mode 100644 index 0000000..250b139 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/person.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "person@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "person@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "person@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/person.imageset/person@1x.png b/SEDaily-IOS/Assets.xcassets/person.imageset/person@1x.png new file mode 100644 index 0000000..b5ee12d Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person.imageset/person@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person.imageset/person@2x.png b/SEDaily-IOS/Assets.xcassets/person.imageset/person@2x.png new file mode 100644 index 0000000..24c4384 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person.imageset/person@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person.imageset/person@3x.png b/SEDaily-IOS/Assets.xcassets/person.imageset/person@3x.png new file mode 100644 index 0000000..1eb267e Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person.imageset/person@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person_outline.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/Contents.json new file mode 100644 index 0000000..1376bc8 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "person_outline@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "person_outline@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "person_outline@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@1x.png b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@1x.png new file mode 100644 index 0000000..4f3efa6 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@2x.png b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@2x.png new file mode 100644 index 0000000..26ff10d Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@3x.png b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@3x.png new file mode 100644 index 0000000..805330a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/person_outline.imageset/person_outline@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play-big.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/play-big.imageset/Contents.json new file mode 100644 index 0000000..c2b1099 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/play-big.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "play-big.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "play-big@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "play-big@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big.png b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big.png new file mode 100644 index 0000000..a6345ee Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@2x.png b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@2x.png new file mode 100644 index 0000000..2ceb32a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@3x.png b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@3x.png new file mode 100644 index 0000000..5f94733 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play-big.imageset/play-big@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/play.imageset/Contents.json new file mode 100644 index 0000000..db7ffa0 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/play.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "play.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "play@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "play@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/play.imageset/play.png b/SEDaily-IOS/Assets.xcassets/play.imageset/play.png new file mode 100644 index 0000000..9642367 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play.imageset/play.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play.imageset/play@2x.png b/SEDaily-IOS/Assets.xcassets/play.imageset/play@2x.png new file mode 100644 index 0000000..d85ee9f Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play.imageset/play@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play.imageset/play@3x.png b/SEDaily-IOS/Assets.xcassets/play.imageset/play@3x.png new file mode 100644 index 0000000..40c7d93 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play.imageset/play@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_audio.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/Contents.json new file mode 100644 index 0000000..8406e6f --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "play_audio.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "play_audio@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "play_audio@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio.png b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio.png new file mode 100644 index 0000000..439f4b3 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@2x.png b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@2x.png new file mode 100644 index 0000000..e159015 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@3x.png b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@3x.png new file mode 100644 index 0000000..123f9e5 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_audio.imageset/play_audio@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_white.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/play_white.imageset/Contents.json new file mode 100644 index 0000000..fa6bd15 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/play_white.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "play_white.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "play_white@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "play_white@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white.png b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white.png new file mode 100644 index 0000000..21e0b69 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@2x.png b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@2x.png new file mode 100644 index 0000000..1a48b52 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@3x.png b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@3x.png new file mode 100644 index 0000000..ad88994 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/play_white.imageset/play_white@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/Contents.json new file mode 100644 index 0000000..ab26a85 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "profile-icon-9.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/profile-icon-9.png b/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/profile-icon-9.png new file mode 100644 index 0000000..bf82252 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/profile-icon-9.imageset/profile-icon-9.png differ diff --git a/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/Contents.json new file mode 100644 index 0000000..e151e21 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "rewind_audio@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "rewind_audio@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "rewind_audio@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@1x.png b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@1x.png new file mode 100644 index 0000000..85fba97 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@2x.png b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@2x.png new file mode 100644 index 0000000..6835bc8 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@3x.png b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@3x.png new file mode 100644 index 0000000..e06bae7 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/rewind_audio.imageset/rewind_audio@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/Contents.json new file mode 100644 index 0000000..19eef55 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "sedaily-logo.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "sedaily-logo@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "sedaily-logo@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo.png b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo.png new file mode 100644 index 0000000..38f3593 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo.png differ diff --git a/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@2x.png b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@2x.png new file mode 100644 index 0000000..0326b7a Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@3x.png b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@3x.png new file mode 100644 index 0000000..9813fcb Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/sedaily-logo.imageset/sedaily-logo@3x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/share.imageset/Contents.json b/SEDaily-IOS/Assets.xcassets/share.imageset/Contents.json new file mode 100644 index 0000000..0fb5038 --- /dev/null +++ b/SEDaily-IOS/Assets.xcassets/share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "share@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "share@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "share@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SEDaily-IOS/Assets.xcassets/share.imageset/share@1x.png b/SEDaily-IOS/Assets.xcassets/share.imageset/share@1x.png new file mode 100644 index 0000000..565ba6c Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/share.imageset/share@1x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/share.imageset/share@2x.png b/SEDaily-IOS/Assets.xcassets/share.imageset/share@2x.png new file mode 100644 index 0000000..04c429b Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/share.imageset/share@2x.png differ diff --git a/SEDaily-IOS/Assets.xcassets/share.imageset/share@3x.png b/SEDaily-IOS/Assets.xcassets/share.imageset/share@3x.png new file mode 100644 index 0000000..509d738 Binary files /dev/null and b/SEDaily-IOS/Assets.xcassets/share.imageset/share@3x.png differ diff --git a/SEDaily-IOS/AudioOverlayViewController.swift b/SEDaily-IOS/AudioOverlayViewController.swift new file mode 100644 index 0000000..0a1e6f2 --- /dev/null +++ b/SEDaily-IOS/AudioOverlayViewController.swift @@ -0,0 +1,404 @@ +// +// AudioViewManager.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 6/29/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +//import UIKit +//import SwiftIcons +//import AVFoundation +//import SnapKit +//import SwifterSwift +//import KTResponsiveUI +//import KoalaTeaPlayer +// +//protocol ExpandButtonDelegate: class { +// func willExpand() +//} + +//protocol AudioOverlayDelegate: class { +// func animateOverlayIn() +// func animateOverlayOut() +// func playAudio(podcastViewModel: PodcastViewModel) +// func pauseAudio() +// func stopAudio() +// func setCurrentShowingDetailView(podcastViewModel: PodcastViewModel?) +//} + +//class AudioOverlayViewController: UIViewController { +// let networkService = API() +// +// // This value adapts based on screen height ration relatively to iPhone X +// static let audioControlsViewHeight: CGFloat = 140 * (812 / UIScreen.main.bounds.height) +// +// +// private static var userSettingPlaybackSpeedKey = "PlaybackSpeed" +// +// /// The instance of `AssetPlaybackManager` that the app uses for managing playback. +// private var assetPlaybackManager: AssetPlayer! = nil +// +// /// The instance of `RemoteCommandManager` that the app uses for managing remote command events. +// private var remoteCommandManager: RemoteCommandManager! = nil +// +// /// The instance of PlayProgressModelController to retrieve and save progress of the playback +// private var progressController = PlayProgressModelController() +// +// +// weak var expandButtonDelegate: ExpandButtonDelegate? +// +// private var audioView: AudioView? +// private var podcastViewModel: PodcastViewModel? +// private let verticalStackView = UIStackView() +// private let horizontalStackView = UIStackView() +// private var currentViewController: UIViewController? +// private weak var audioOverlayDelegate: AudioOverlayDelegate? +// +// private var upvoteService: UpvoteService? +// private var bookmarkService: BookmarkService? +// +// init(audioOverlayDelegate: AudioOverlayDelegate) { +// self.audioOverlayDelegate = audioOverlayDelegate +// super.init(nibName: nil, bundle: nil) +// } +// +// required init?(coder aDecoder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// override func viewDidLoad() { +// +// self.verticalStackView.axis = .vertical +// self.view.addSubview(self.verticalStackView) +// +// horizontalStackView.axis = .horizontal +// horizontalStackView.distribution = .fillEqually +// self.verticalStackView.addArrangedSubview(horizontalStackView) +// +// self.audioView = AudioView(frame: CGRect.zero, audioViewDelegate: self) +// if let audioView = self.audioView { +// horizontalStackView.addArrangedSubview(audioView) +// } +// +// horizontalStackView.snp.makeConstraints { (make) in +// make.height.equalTo( +// UIView.getValueScaledByScreenHeightFor( +// baseValue: AudioOverlayViewController.audioControlsViewHeight)) +// } +// +// self.verticalStackView.snp.makeConstraints { (make) in +// make.edges.equalToSuperview() +// } +// } +// +// func animateIn() { +// self.view.snp.updateConstraints { (make) in +// make.bottom.equalToSuperview().offset(0) +// make.top.equalToSuperview().offset( +// UIScreen.main.bounds.height - +// UIView.getValueScaledByScreenHeightFor( +// baseValue: AudioOverlayViewController.audioControlsViewHeight)) +// } +// +// UIView.animate(withDuration: 0.25) { +// self.view.superview?.layoutIfNeeded() +// } +// +// self.audioView?.showSliders() +// } +// +// func animateOut() { +// self.view.snp.updateConstraints { (make) in +// make.bottom.equalToSuperview().offset( +// UIView.getValueScaledByScreenHeightFor( +// baseValue: AudioOverlayViewController.audioControlsViewHeight)) +// make.top.equalToSuperview().offset(UIScreen.main.bounds.height) +// } +// +// UIView.animate(withDuration: 0.25) { +// self.view.superview?.layoutIfNeeded() +// } +// +// self.audioView?.hideSliders() +// } +// +// @objc func closeButtonPressed() { +// self.audioOverlayDelegate?.animateOverlayOut() +// } +// +// func playAudio(podcastViewModel: PodcastViewModel) { +// self.podcastViewModel = podcastViewModel +// Tracker.logPlayPodcast(podcast: podcastViewModel) +// +// self.audioView?.hideExpandCollapseButton() +// self.setText(text: podcastViewModel.podcastTitle) +// self.saveProgress() +// self.loadAudio(podcastViewModel: podcastViewModel) +// self.createPodcastDetailViewController(podcastViewModel: podcastViewModel) +// //CurrentlyPlaying.shared.setCurrentlyPlaying(id: podcastViewModel._id) +// // TODO: only mark if logged in +// networkService.markAsListened(postId: podcastViewModel._id) +// Analytics2.podcastPlayed(podcastId: podcastViewModel._id) +// } +// +// func pauseAudio() { +// self.pauseButtonPressed() +// } +// +// func stopAudio() { +// self.stopButtonPressed() +// //CurrentlyPlaying.shared.setCurrentlyPlaying(id: "") +// } +// +// private func saveProgress() { +// +// guard self.podcastViewModel != nil else { return } +// progressController.retrieve() +// } +// +// private func loadAudio(podcastViewModel: PodcastViewModel) { +// var fileURL: URL? = nil +// fileURL = podcastViewModel.mp3URL +// if let urlString = podcastViewModel.downloadedFileURLString { +// fileURL = URL(fileURLWithPath: urlString) +// } +// guard let url = fileURL else { return } +// self.setupAudioManager( +// url: url, +// podcastViewModel: podcastViewModel) +// } +// +// private func createPodcastDetailViewController(podcastViewModel: PodcastViewModel) { +// if let currentViewController = self.currentViewController { +// self.verticalStackView.removeArrangedSubview(currentViewController.view) +// currentViewController.willMove(toParentViewController: nil) +// currentViewController.view.removeFromSuperview() +// currentViewController.removeFromParentViewController() +// } +// +// +// let podcastDetailViewController = EpisodeViewController() +// podcastDetailViewController.viewModel = podcastViewModel +// +// let navVC = UINavigationController(rootViewController: podcastDetailViewController) +// navVC.view.backgroundColor = .white +// self.addChildViewController(navVC) +// +// self.verticalStackView.insertArrangedSubview(navVC.view, at: 0) +// self.verticalStackView.sendSubview(toBack: navVC.view) +// navVC.didMove(toParentViewController: self) +// +// self.currentViewController = navVC +// } +// +// fileprivate func setupAudioManager(url: URL, podcastViewModel: PodcastViewModel) { +// var savedTime: Float = 0 +// +// //Load Saved time +// +// +// if progressController.episodesPlayProgress[podcastViewModel._id] != nil { +// savedTime = progressController.episodesPlayProgress[podcastViewModel._id]!.currentTime +// } else { +// progressController.episodesPlayProgress[podcastViewModel._id]?.currentTime = 0 +// } +// +// log.info(savedTime, "savedtime") +// +// let asset = Asset(assetName: podcastViewModel.podcastTitle, url: url, savedTime: savedTime) +// assetPlaybackManager = AssetPlayer(asset: asset) +// assetPlaybackManager.playerDelegate = self +// +// // If you want remote commands +// // Initializer the `RemoteCommandManager`. +// self.remoteCommandManager = RemoteCommandManager(assetPlaybackManager: assetPlaybackManager) +// +// // Always enable playback commands in MPRemoteCommandCenter. +// self.remoteCommandManager.activatePlaybackCommands(true) +// self.remoteCommandManager.toggleChangePlaybackPositionCommand(true) +// self.remoteCommandManager.toggleSkipBackwardCommand(true, interval: 30) +// self.remoteCommandManager.toggleSkipForwardCommand(true, interval: 30) +// self.remoteCommandManager.toggleChangePlaybackPositionCommand(true) +// } +// +// fileprivate func triggerRemoveContainerViewInset() { +// self.audioOverlayDelegate?.animateOverlayOut() +// } +// +// fileprivate func setText(text: String?) { +// audioView?.setText(text: text) +// } +// +// //@TODO: Switch all handling of enabled parts of audio view to here +// //@TODO: Add manager param and update everything here (maybe) +// fileprivate func handleStateChange(for state: AssetPlayerPlaybackState) { +// if let podcastViewModel = self.podcastViewModel { +// self.setText(text: podcastViewModel.podcastTitle) +// } +// +// switch state { +// case .setup: +// audioView?.isFirstLoad = true +// audioView?.disableButtons() +// audioView?.startActivityAnimating() +// +// audioView?.playButton.isHidden = false +// audioView?.pauseButton.isHidden = true +// case .playing: +// audioView?.enableButtons() +// audioView?.stopActivityAnimating() +// +// audioView?.playButton.isHidden = true +// audioView?.pauseButton.isHidden = false +// case .paused: +// audioView?.enableButtons() +// audioView?.stopActivityAnimating() +// +// audioView?.playButton.isHidden = false +// audioView?.pauseButton.isHidden = true +// case .interrupted: +// //@TODO: handle interrupted +// break +// case .failed: +// self.audioOverlayDelegate?.animateOverlayOut() +// case .buffering: +// audioView?.startActivityAnimating() +// +// //audioView?.stopButton.isEnabled = true +// audioView?.playButton.isHidden = false +// audioView?.pauseButton.isHidden = true +// case .stopped: +// self.triggerRemoveContainerViewInset() +// self.audioOverlayDelegate?.animateOverlayOut() +// // change play/stop button state +// //CurrentlyPlaying.shared.setCurrentlyPlaying(id: "") +// let userInfo = ["viewModel": podcastViewModel] +// NotificationCenter.default.post(name: .reloadEpisodeView, object: nil, userInfo: userInfo) +// } +// } +//} +// +//extension AudioOverlayViewController: AssetPlayerDelegate { +// func currentAssetDidChange(_ player: AssetPlayer) { +// log.debug("asset did change") +// if let playbackSpeedValue = UserDefaults.standard.object(forKey: AudioOverlayViewController.userSettingPlaybackSpeedKey) as? Float, +// let playbackSpeed = PlaybackSpeed(rawValue: playbackSpeedValue) { +// audioView?.currentSpeed = playbackSpeed +// audioRateChanged(newRate: playbackSpeedValue) +// } else { +// audioView?.currentSpeed = ._1x +// } +// } +// +// func playerIsSetup(_ player: AssetPlayer) { +// audioView?.updateSlider(maxValue: player.maxSecondValue) +// } +// +// func playerPlaybackStateDidChange(_ player: AssetPlayer) { +// guard let state = player.state else { return } +// self.handleStateChange(for: state) +// } +// +// func playerCurrentTimeDidChange(_ player: AssetPlayer) { +// +// // Update progress +// guard let podcastViewModel = self.podcastViewModel else { return } +// let progress = PlayProgress(id: podcastViewModel._id, currentTime: Float(player.currentTime), totalLength: Float(player.maxSecondValue)) +// progressController.episodesPlayProgress[podcastViewModel._id] = progress +// +// if round(player.currentTime).truncatingRemainder(dividingBy: 5.0) == 0.0 { +// progressController.save() +// } +// +// audioView?.updateTimeLabels(currentTimeText: player.timeElapsedText, timeLeftText: player.timeLeftText) +// +// audioView?.updateSlider(currentValue: Float(player.currentTime)) +// } +// +// func playerPlaybackDidEnd(_ player: AssetPlayer) { +// // Reset progress +// if let podcastViewModel = self.podcastViewModel { +// progressController.episodesPlayProgress[podcastViewModel._id]?.currentTime = 0.0 +// progressController.save() +// } +// } +// +// func playerIsLikelyToKeepUp(_ player: AssetPlayer) { +// //@TODO: Nothing to do here? +// } +// +// func playerBufferTimeDidChange(_ player: AssetPlayer) { +// audioView?.updateBufferSlider(bufferValue: player.bufferedTime) +// } +// +//} +// +//extension AudioOverlayViewController: AudioViewDelegate { +// func playbackSliderValueChanged(value: Float) { +// let cmTime = CMTimeMake(Int64(value), 1) +// assetPlaybackManager?.seekTo(cmTime) +// } +// +// func playButtonPressed() { +// assetPlaybackManager?.play() +// } +// +// func pauseButtonPressed() { +// assetPlaybackManager?.pause() +// } +// +// func stopButtonPressed() { +// self.audioOverlayDelegate?.animateOverlayOut() +// assetPlaybackManager?.stop() // +// } +// +// func skipForwardButtonPressed() { +// assetPlaybackManager?.skipForward(30) +// } +// +// func expandButtonPressed() { +// +// +// +// self.view.snp.updateConstraints({ (make) in +// make.top.equalToSuperview().offset(0) +// }) +// +// UIView.animate(withDuration: 0.25) { +// self.view.superview?.layoutIfNeeded() +// } +// } +// +// func collapseButtonPressed() { +// self.view.snp.updateConstraints({ (make) in +// make.top.equalToSuperview().offset( +// UIScreen.main.bounds.height - +// UIView.getValueScaledByScreenHeightFor( +// baseValue: AudioOverlayViewController.audioControlsViewHeight)) +// }) +// UIView.animate(withDuration: 0.25) { +// self.view.superview?.layoutIfNeeded() +// } +// } +// +// func skipBackwardButtonPressed() { +// assetPlaybackManager?.skipBackward(30) +// } +// +// func audioRateChanged(newRate: Float) { +// assetPlaybackManager?.changePlayerPlaybackRate(to: newRate) +// UserDefaults.standard.set(newRate, forKey: AudioOverlayViewController.userSettingPlaybackSpeedKey) +// } +// +// func setCurrentShowingDetailView(podcastViewModel: PodcastViewModel?) { +// self.audioView?.showExpandCollapseButton() +// if let podcastViewModel = podcastViewModel, +// let currentPlayingPodcastViewModel = self.podcastViewModel { +// if currentPlayingPodcastViewModel._id == podcastViewModel._id { +// self.audioView?.hideExpandCollapseButton() +// } +// } +// } +// +//} diff --git a/SEDaily-IOS/AudioPlayerView.swift b/SEDaily-IOS/AudioPlayerView.swift new file mode 100644 index 0000000..9f1f984 --- /dev/null +++ b/SEDaily-IOS/AudioPlayerView.swift @@ -0,0 +1,522 @@ +// +// AudioPlayerView.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 29/06/2019. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import UIKit +import AVFoundation + +public protocol AudioPlayerViewDelegate: NSObjectProtocol { + func playButtonPressed() + func pauseButtonPressed() + func skipForwardButtonPressed() + func skipBackwardButtonPressed() + func detailsButtonPressed() + func collapseButtonPressed() + func audioRateChanged(newRate: Float) + func playbackSliderValueChanged(value: Float) +} + +class AudioPlayerView: UIView { + + private let imageView: UIImageView = UIImageView() + private let skipForwardButton = UIButton() + private let skipBackwardButton = UIButton() + private let playbackSpeedButton = UIButton() + + private let infoButton = UIButton() + private let collapseButton = UIButton() + private var activityView: UIActivityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .white) + + private var bufferSlider = UISlider(frame: .zero) + private var bufferBackgroundSlider = UISlider(frame: .zero) + private var playbackSlider = UISlider(frame: .zero) + private var currentTimeLabel = UILabel() + private var timeLeftLabel = UILabel() + private var previousSliderValue: Float = 0.0 + + private let stackView = UIStackView() + private let separator: UIView = UIView() + private let label = UILabel() + + let playButton = UIButton() + let pauseButton = UIButton() + + + + var viewModel: PodcastViewModel = PodcastViewModel() { + didSet { + update() + } + } + + weak var audioViewDelegate: AudioPlayerViewDelegate? + + var expanded: Bool = false { + didSet { + adjustLayout() + } + } + + var currentSpeed: PlaybackSpeed = ._1x { + willSet { + guard currentSpeed != newValue else { return } + self.playbackSpeedButton.setTitle(newValue.shortTitle, for: .normal) + self.audioViewDelegate?.audioRateChanged(newRate: newValue.rawValue) + } + } + + var alertController: UIAlertController! { + let alert = UIAlertController(title: "Change Playback Speed", message: "Current Speed: \(self.currentSpeed.shortTitle)", preferredStyle: .actionSheet) + let times: [PlaybackSpeed] = [._1x, ._1_2x, ._1_4x, ._1_6x, ._1_8x, ._2x, ._2_5x, ._3x] + + times.forEach({ (time) in + let title = time.title + alert.addAction(UIAlertAction(title: title, style: .default) { _ in + self.currentSpeed = time + }) + }) + alert.addAction(title: "Cancel", style: .cancel, isEnabled: true) {_ in + self.alertController.dismiss(animated: true, completion: nil) + } + return alert + } + + init(frame: CGRect, audioViewDelegate: AudioPlayerViewDelegate) { + self.audioViewDelegate = audioViewDelegate + super.init(frame: frame) + self.performLayout() + setupActivityIndicator() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func startActivityAnimating() { + self.activityView.startAnimating() + } + + func stopActivityAnimating() { + self.activityView.stopAnimating() + } + + func enableButtons() { + log.warning("enabling buttons") + self.playButton.isEnabled = true + self.pauseButton.isEnabled = true + self.skipForwardButton.isEnabled = true + self.skipBackwardButton.isEnabled = true + } + + func disableButtons() { + log.warning("disabling buttons") + self.playButton.isEnabled = false + self.pauseButton.isEnabled = false + self.skipForwardButton.isEnabled = false + self.skipBackwardButton.isEnabled = false + } + +} +extension AudioPlayerView { + private func update() { + label.text = viewModel.podcastTitle + imageView.kf.setImage(with: self.expanded ? self.viewModel.featuredImageURL : self.viewModel.guestImageURL, placeholder: UIImage(named: "Logo_BarButton")) + } +} + +extension AudioPlayerView { + override func performLayout() { + + func addPlaybackSlider(parentView: UIView) { + + addBufferSlider(parentView: parentView) + + playbackSlider.minimumValue = 0 + playbackSlider.isContinuous = true + playbackSlider.minimumTrackTintColor = Stylesheet.Colors.base + playbackSlider.maximumTrackTintColor = .clear + playbackSlider.layer.cornerRadius = 0 + playbackSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) + playbackSlider.isUserInteractionEnabled = false + + parentView.addSubview(playbackSlider) + self.bringSubview(toFront: playbackSlider) + + playbackSlider.snp.makeConstraints { (make) -> Void in + make.top.equalTo(label.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 30)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) + make.left.right.equalToSuperview().inset(15).priority(999) + + } + let smallCircle = #imageLiteral(resourceName: "SmallCircle").filled(withColor: Stylesheet.Colors.base) + playbackSlider.setThumbImage(smallCircle, for: .normal) + + let bigCircle = #imageLiteral(resourceName: "BigCircle").filled(withColor: Stylesheet.Colors.base) + playbackSlider.setThumbImage(bigCircle, for: .highlighted) + } + + func addBufferSlider(parentView: UIView) { + bufferBackgroundSlider.minimumValue = 0 + bufferBackgroundSlider.isContinuous = true + bufferBackgroundSlider.tintColor = Stylesheet.Colors.bufferColor + bufferBackgroundSlider.layer.cornerRadius = 0 + bufferBackgroundSlider.alpha = 0.5 + bufferBackgroundSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) + bufferBackgroundSlider.isUserInteractionEnabled = false + + parentView.addSubview(bufferBackgroundSlider) + + bufferBackgroundSlider.snp.makeConstraints { (make) -> Void in + make.top.equalTo(label.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 30)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) + make.left.right.equalToSuperview().inset(15).priority(999) + } + + bufferBackgroundSlider.setThumbImage(UIImage(), for: .normal) + + bufferSlider.minimumValue = 0 + bufferSlider.isContinuous = true + bufferSlider.minimumTrackTintColor = Stylesheet.Colors.bufferColor + bufferSlider.maximumTrackTintColor = .clear + bufferSlider.layer.cornerRadius = 0 + bufferSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) + bufferSlider.isUserInteractionEnabled = false + + parentView.addSubview(bufferSlider) + + bufferSlider.snp.makeConstraints { (make) -> Void in + make.top.equalTo(label.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 30)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) + make.left.right.equalToSuperview().inset(15).priority(999) + } + + bufferSlider.setThumbImage(UIImage(), for: .normal) + } + + func addLabels(parentView: UIView) { + let labelFontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 12) + currentTimeLabel.text = "00:00" + currentTimeLabel.textAlignment = .left + currentTimeLabel.font = UIFont.systemFont(ofSize: labelFontSize) + + timeLeftLabel.text = "00:00" + timeLeftLabel.textAlignment = .right + timeLeftLabel.adjustsFontSizeToFitWidth = true + timeLeftLabel.font = UIFont.systemFont(ofSize: labelFontSize) + + parentView.addSubview(currentTimeLabel) + parentView.addSubview(timeLeftLabel) + + currentTimeLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(playbackSlider) + make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) + } + + timeLeftLabel.snp.makeConstraints { (make) -> Void in + make.right.equalTo(playbackSlider) + make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) + } + } + + self.backgroundColor = Stylesheet.Colors.dark + + stackView.addArrangedSubview(playbackSpeedButton) + stackView.addArrangedSubview(skipBackwardButton) + stackView.addArrangedSubview(playButton) + stackView.addArrangedSubview(activityView) + stackView.addArrangedSubview(pauseButton) + stackView.addArrangedSubview(skipForwardButton) + stackView.addArrangedSubview(infoButton) + + + addSubview(stackView) + addSubview(label) + addSubview(imageView) + addSubview(separator) + addSubview(collapseButton) + addPlaybackSlider(parentView: self) + addLabels(parentView: self) + + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .equalSpacing + + skipForwardButton.setImage(#imageLiteral(resourceName: "forward_audio"), for: .normal) + skipForwardButton.addTarget(self, action: #selector(self.skipForwardButtonPressed), for: .touchUpInside) + + + skipBackwardButton.setImage(#imageLiteral(resourceName: "rewind_audio"), for: .normal) + skipBackwardButton.addTarget(self, action: #selector(self.skipBackwardButtonPressed), for: .touchUpInside) + + playButton.setImage(#imageLiteral(resourceName: "play_white"), for: .normal) + playButton.addTarget(self, action: #selector(self.playButtonPressed), for: .touchUpInside) + + pauseButton.setImage(#imageLiteral(resourceName: "pause_white"), for: .normal) + pauseButton.addTarget(self, action: #selector(self.pauseButtonPressed), for: .touchUpInside) + pauseButton.isHidden = true + + playbackSpeedButton.setTitle(PlaybackSpeed._1x.shortTitle, for: .normal) + playbackSpeedButton.titleLabel?.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 20)) + playbackSpeedButton.setTitleColor(Stylesheet.Colors.base, for: .normal) + playbackSpeedButton.addTarget(self, action: #selector(self.settingsButtonPressed), for: .touchUpInside) + + infoButton.setImage(#imageLiteral(resourceName: "info"), for: .normal) + infoButton.addTarget(self, action: #selector(AudioPlayerView.infoTapped), for: .touchUpInside) + + + collapseButton.setImage(#imageLiteral(resourceName: "Arrow-Down"), for: .normal) + collapseButton.addTarget(self, action: #selector(AudioPlayerView.collapseTapped), for: .touchUpInside) + + label.text = viewModel.podcastTitle + + separator.backgroundColor = .lightGray + } + + @objc func playbackSliderValueChanged(_ slider: UISlider) { + let timeInSeconds = slider.value + if (playbackSlider.isTracking) && (timeInSeconds != previousSliderValue) { + playbackSlider.value = timeInSeconds + let duration = playbackSlider.maximumValue + let timeLeft = Float(duration - timeInSeconds) + + let currentTimeString = Helpers.createTimeString(time: timeInSeconds, units: [.minute, .second]) + let timeLeftString = Helpers.createTimeString(time: timeLeft, units: [.minute, .second]) + self.currentTimeLabel.text = currentTimeString + self.timeLeftLabel.text = timeLeftString + } else { + self.audioViewDelegate?.playbackSliderValueChanged(value: timeInSeconds) + let duration = playbackSlider.maximumValue + let timeLeft = Float(duration - timeInSeconds) + let currentTimeString = Helpers.createTimeString(time: timeInSeconds, units: [.minute, .second]) + let timeLeftString = Helpers.createTimeString(time: timeLeft, units: [.minute, .second]) + self.currentTimeLabel.text = currentTimeString + self.timeLeftLabel.text = timeLeftString + } + previousSliderValue = timeInSeconds + } + + @objc func settingsButtonPressed() { + self.parentViewController?.present(alertController, animated: true, completion: nil) + } + + @objc func collapseTapped() { + audioViewDelegate?.collapseButtonPressed() + } + + @objc func infoTapped() { + audioViewDelegate?.detailsButtonPressed() + } + + + func updateSlider(maxValue: Float) { + guard playbackSlider.maximumValue >= 0.0 else { return } + + if playbackSlider.isUserInteractionEnabled == false { + playbackSlider.isUserInteractionEnabled = true + } + + playbackSlider.maximumValue = maxValue + bufferSlider.maximumValue = maxValue + } + + func updateSlider(currentValue: Float) { + guard !playbackSlider.isTracking else { return } + playbackSlider.value = currentValue + } + + func updateBufferSlider(bufferValue: Float) { + bufferSlider.value = bufferValue + } + + func updateTimeLabels(currentTimeText: String, timeLeftText: String) { + guard !playbackSlider.isTracking else { return } + self.currentTimeLabel.text = currentTimeText + self.timeLeftLabel.text = timeLeftText + } + + func setText(text: String?) { + label.text = text ?? "" + } + + func setupActivityIndicator() { +// addSubview(activityView) +// activityView.snp.makeConstraints { (make) -> Void in +// make.centerX.equalToSuperview() +// make.top.equalTo(playButton.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) +// } + } + +} + +extension AudioPlayerView { + @objc func playButtonPressed() { + self.audioViewDelegate?.playButtonPressed() + } + + @objc func pauseButtonPressed() { + self.audioViewDelegate?.pauseButtonPressed() + } + + @objc func skipForwardButtonPressed() { + self.audioViewDelegate?.skipForwardButtonPressed() + } + + @objc func skipBackwardButtonPressed() { + self.audioViewDelegate?.skipBackwardButtonPressed() + } +} + +extension AudioPlayerView { + + private func prepareForCollapsed() { + + self.backgroundColor = Stylesheet.Colors.white + + bufferSlider.isHidden = true + bufferBackgroundSlider.isHidden = true + playbackSlider.isHidden = true + currentTimeLabel.isHidden = true + timeLeftLabel.isHidden = true + + collapseButton.isHidden = true + + skipForwardButton.isHidden = true + skipBackwardButton.isHidden = true + + infoButton.isHidden = true + playbackSpeedButton.isHidden = true + + label.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + label.textColor = .white + label.textAlignment = .left + label.numberOfLines = 2 + label.isUserInteractionEnabled = false + + playButton.setImage(#imageLiteral(resourceName: "play_white"), for: .normal) + pauseButton.setImage(#imageLiteral(resourceName: "pause_white"), for: .normal) + + activityView.activityIndicatorViewStyle = .white + + imageView.layer.cornerRadius = 20.0 + imageView.layer.masksToBounds = true + + playButton.snp.remakeConstraints { (make) -> Void in + make.size.equalTo(40).priority(999) + } + pauseButton.snp.remakeConstraints { (make) -> Void in + make.size.equalTo(40).priority(999) + } + stackView.snp.remakeConstraints { (make) -> Void in + make.right.equalToSuperview().inset(15.0) + make.centerY.equalToSuperview() + } + imageView.snp.remakeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(10.0) + make.centerY.equalToSuperview() + make.size.equalTo(40) + } + label.snp.remakeConstraints { (make) -> Void in + make.left.equalTo(imageView.snp.right).offset(15.0).priority(999) + make.right.equalToSuperview().inset(60) + make.centerY.equalToSuperview() + } + separator.snp.remakeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(0.3) + } + } + + private func prepareForExpanded() { + + self.backgroundColor = Stylesheet.Colors.white + + bufferSlider.isHidden = false + bufferBackgroundSlider.isHidden = false + playbackSlider.isHidden = false + currentTimeLabel.isHidden = false + timeLeftLabel.isHidden = false + + collapseButton.isHidden = false + + label.numberOfLines = 3 + + + playButton.setImage(#imageLiteral(resourceName: "play-big"), for: .normal) + pauseButton.setImage(#imageLiteral(resourceName: "pause-big"), for: .normal) + + label.font = UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 20)) + label.textAlignment = .center + label.textColor = Stylesheet.Colors.dark + let tapOnLabel = UITapGestureRecognizer(target: self, action: #selector(AudioPlayerView.infoTapped)) + label.addGestureRecognizer(tapOnLabel) + label.isUserInteractionEnabled = true + + skipForwardButton.isHidden = false + skipBackwardButton.isHidden = false + + infoButton.isHidden = false + playbackSpeedButton.isHidden = false + + imageView.layer.cornerRadius = 0.0 + imageView.contentMode = .scaleAspectFit + + activityView.activityIndicatorViewStyle = .gray + + stackView.snp.remakeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(20) + make.right.equalToSuperview().inset(20) + make.top.equalTo(playbackSlider.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 30)) + make.centerX.equalToSuperview() + } + collapseButton.snp.remakeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(5) + if #available(iOS 11.0, *) { + make.top.equalTo(safeAreaLayoutGuide).offset(10.0) + } else { + // Fallback on earlier versions + make.top.equalToSuperview().offset(10.0) + } + make.size.equalTo(50) + } + playButton.snp.remakeConstraints { (make) -> Void in + make.size.equalTo(80).priority(999) + } + pauseButton.snp.remakeConstraints { (make) -> Void in + make.size.equalTo(80).priority(999) + } + + imageView.snp.remakeConstraints { (make) -> Void in + make.left.right.equalToSuperview() + make.top.equalTo(collapseButton.snp.bottom).offset(20.0) + make.height.equalTo(200) + } + label.snp.remakeConstraints { (make) -> Void in + make.top.equalTo(imageView.snp.bottom).offset(30.0) + make.rightMargin.leftMargin.equalToSuperview().inset(20.0) + } + } + + func adjustLayout() { + self.expanded ? prepareForExpanded() : prepareForCollapsed() + UIView.animate(withDuration: 0.2, animations: { + self.layoutIfNeeded() + }) + UIView.transition(with: imageView, + duration: 0.2, + options: .transitionCrossDissolve, + animations: { self.imageView.kf.setImage(with: self.expanded ? self.viewModel.featuredImageURL : self.viewModel.guestImageURL , placeholder: UIImage(named: "Logo_BarButton"), options: [.transition(.fade(0.2))]) + self.backgroundColor = self.expanded ? .white : Stylesheet.Colors.dark + }, + completion: nil) + } +} + diff --git a/SEDaily-IOS/AudioView.swift b/SEDaily-IOS/AudioView.swift index 73e380a..060299e 100644 --- a/SEDaily-IOS/AudioView.swift +++ b/SEDaily-IOS/AudioView.swift @@ -1,463 +1,532 @@ +//// +//// AudioView.swift +//// SEDaily-IOS +//// +//// Created by Craig Holliday on 6/30/17. +//// Copyright © 2017 Koala Tea. All rights reserved. +//// // -// AudioView.swift -// SEDaily-IOS -// -// Created by Craig Holliday on 6/30/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -import UIKit -import AVFoundation -import SwifterSwift -import KTResponsiveUI - -enum PlaybackSpeed: Float { - case _1x = 1.0 - case _1_2x = 1.2 - case _1_4x = 1.4 - case _1_6x = 1.6 - case _1_8x = 1.8 - case _2x = 2.0 - case _2_5x = 2.5 - case _3x = 3.0 - - var title: String { - switch self { - case ._1x: - return "1x (Normal Speed)" - case ._1_2x: - return "1.2x" - case ._1_4x: - return "1.4x" - case ._1_6x: - return "1.6x" - case ._1_8x: - return "1.8x" - case ._2x: - return "⏩ 2x ⏩" - case ._2_5x: - return "2.5x" - case ._3x: - return "🔥 3x 🔥" - } - } - - var shortTitle: String { - switch self { - case ._1x: - return "1x" - case ._1_2x: - return "1.2x" - case ._1_4x: - return "1.4x" - case ._1_6x: - return "1.6x" - case ._1_8x: - return "1.8x" - case ._2x: - return "2x" - case ._2_5x: - return "2.5x" - case ._3x: - return "3x" - } - } -} - -// MARK: - PlayerDelegate - -/// Player delegate protocol -public protocol AudioViewDelegate: NSObjectProtocol { - func playButtonPressed() - func pauseButtonPressed() - func stopButtonPressed() - func skipForwardButtonPressed() - func skipBackwardButtonPressed() - func audioRateChanged(newRate: Float) - func playbackSliderValueChanged(value: Float) -} - -class AudioView: UIView { - open weak var delegate: AudioViewDelegate? - - var activityView: UIActivityIndicatorView! - - var podcastLabel = UILabel() - fileprivate var containerView = UIView() - fileprivate var stackView = UIStackView() - var skipForwardButton = UIButton() - var skipBackwardbutton = UIButton() - var playButton = UIButton() - var pauseButton = UIButton() - var stopButton = UIButton() - - var bufferSlider = UISlider(frame: .zero) - var bufferBackgroundSlider = UISlider(frame: .zero) - var playbackSlider = UISlider(frame: .zero) - - var currentTimeLabel = UILabel() - var timeLeftLabel = UILabel() - - var previousSliderValue: Float = 0.0 - var isFirstLoad = true - var playbackSpeedButton = UIButton() - - var currentSpeed: PlaybackSpeed = ._1x { - willSet { - guard currentSpeed != newValue else { return } - self.playbackSpeedButton.setTitle(newValue.shortTitle, for: .normal) - self.delegate?.audioRateChanged(newRate: newValue.rawValue) - } - } - - var alertController: UIAlertController! { - get { - let alert = UIAlertController(title: "Change Playback Speed", message: "Current Speed: \(self.currentSpeed.shortTitle)", preferredStyle: .actionSheet) - let times: [PlaybackSpeed] = [._1x,._1_2x,._1_4x,._1_6x,._1_8x,._2x,._2_5x,._3x] - - times.forEach({ (time) in - let title = time.title - alert.addAction(UIAlertAction(title: title, style: .default) { action in - self.currentSpeed = time - }) - }) - alert.addAction(title: "Cancel", style: .cancel, isEnabled: true) { (action) in - self.alertController.dismiss(animated: true, completion: nil) - } - return alert - } - } - - override init(frame: CGRect) { - super.init(frame: frame); - - self.performLayout() - self.disableButtons() - } - - required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented"); } - - internal override func performLayout() { - containerView.backgroundColor = .white - self.addSubview(containerView) - - containerView.snp.makeConstraints { (make) -> Void in - make.edges.equalToSuperview() - } - - containerView.addSubview(podcastLabel) - - podcastLabel.snp.makeConstraints { (make) -> Void in - make.left.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 60)) - make.centerX.equalToSuperview() - make.centerY.equalToSuperview().inset(UIView.getValueScaledByScreenHeightFor(baseValue: -30)) - } - - podcastLabel.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 16)) - podcastLabel.numberOfLines = 0 - podcastLabel.textAlignment = .center - - containerView.addSubview(stackView) - - stackView.axis = .horizontal - stackView.alignment = .fill - stackView.distribution = .fillEqually - - stackView.snp.makeConstraints { (make) -> Void in - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 70)) - make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: (50 * 5))) - make.top.equalTo(podcastLabel.snp.bottom) - make.centerX.equalToSuperview() - } - - stackView.addArrangedSubview(skipBackwardbutton) - stackView.addArrangedSubview(stopButton) - stackView.addArrangedSubview(playButton) - stackView.addArrangedSubview(pauseButton) - stackView.addArrangedSubview(skipForwardButton) - - let iconHeight = UIView.getValueScaledByScreenHeightFor(baseValue: (70 / 2)) - - skipBackwardbutton.setImage(#imageLiteral(resourceName: "Backward"), for: .normal) - skipBackwardbutton.height = iconHeight - skipBackwardbutton.tintColor = Stylesheet.Colors.secondaryColor - - playButton.setIcon(icon: .fontAwesome(.play), iconSize: iconHeight, color: Stylesheet.Colors.secondaryColor, forState: .normal) - pauseButton.setIcon(icon: .fontAwesome(.pause), iconSize: iconHeight, color: Stylesheet.Colors.secondaryColor, forState: .normal) - stopButton.setIcon(icon: .fontAwesome(.stop), iconSize: iconHeight, color: Stylesheet.Colors.secondaryColor, forState: .normal) - - skipForwardButton.setImage(#imageLiteral(resourceName: "Forward"), for: .normal) - skipForwardButton.height = iconHeight - skipForwardButton.tintColor = Stylesheet.Colors.secondaryColor - - skipBackwardbutton.addTarget(self, action: #selector(self.skipBackwardButtonPressed), for: .touchUpInside) - playButton.addTarget(self, action: #selector(self.playButtonPressed), for: .touchUpInside) - pauseButton.addTarget(self, action: #selector(self.pauseButtonPressed), for: .touchUpInside) - stopButton.addTarget(self, action: #selector(self.stopButtonPressed), for: .touchUpInside) - skipForwardButton.addTarget(self, action: #selector(self.skipForwardButtonPressed), for: .touchUpInside) - - playButton.isHidden = true - - playbackSpeedButton.titleLabel?.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 20)) - playbackSpeedButton.setTitle(PlaybackSpeed._1x.shortTitle, for: .normal) - playbackSpeedButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .normal) - playbackSpeedButton.addTarget(self, action: #selector(self.settingsButtonPressed), for: .touchUpInside) - self.addSubview(playbackSpeedButton) - - let width = UIView.getValueScaledByScreenWidthFor(baseValue: 40) - let height = UIView.getValueScaledByScreenHeightFor(baseValue: 40) - playbackSpeedButton.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - make.bottom.equalToSuperview() - make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 2)) - } - - setupActivityIndicator() - addPlaybackSlider() - addLabels() - } - - func addPlaybackSlider() { - addBufferSlider() - - playbackSlider.minimumValue = 0 - playbackSlider.isContinuous = true - playbackSlider.minimumTrackTintColor = Stylesheet.Colors.secondaryColor - playbackSlider.maximumTrackTintColor = .clear - playbackSlider.layer.cornerRadius = 0 - playbackSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) - playbackSlider.isUserInteractionEnabled = false - - self.addSubview(playbackSlider) - self.bringSubview(toFront: playbackSlider) - - playbackSlider.snp.makeConstraints { (make) -> Void in - make.top.equalToSuperview().inset(-10) - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) - make.left.right.equalToSuperview() - } - - let smallCircle = #imageLiteral(resourceName: "SmallCircle").filled(withColor: Stylesheet.Colors.secondaryColor) - playbackSlider.setThumbImage(smallCircle, for: .normal) - - let bigCircle = #imageLiteral(resourceName: "BigCircle").filled(withColor: Stylesheet.Colors.secondaryColor) - playbackSlider.setThumbImage(bigCircle, for: .highlighted) - } - - func addBufferSlider() { - // Background Buffer Slider - bufferBackgroundSlider.minimumValue = 0 - bufferBackgroundSlider.isContinuous = true - bufferBackgroundSlider.tintColor = Stylesheet.Colors.bufferColor - bufferBackgroundSlider.layer.cornerRadius = 0 - bufferBackgroundSlider.alpha = 0.5 - bufferBackgroundSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) - bufferBackgroundSlider.isUserInteractionEnabled = false - - self.addSubview(bufferBackgroundSlider) - - bufferBackgroundSlider.snp.makeConstraints { (make) -> Void in - make.top.equalToSuperview().inset(-10) - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) - make.left.right.equalToSuperview() - } - - bufferBackgroundSlider.setThumbImage(UIImage(), for: .normal) - - bufferSlider.minimumValue = 0 - bufferSlider.isContinuous = true - bufferSlider.minimumTrackTintColor = Stylesheet.Colors.bufferColor - bufferSlider.maximumTrackTintColor = .clear - bufferSlider.layer.cornerRadius = 0 - bufferSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) - bufferSlider.isUserInteractionEnabled = false - - self.addSubview(bufferSlider) - - bufferSlider.snp.makeConstraints { (make) -> Void in - make.top.equalToSuperview().inset(-10) - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) - make.left.right.equalToSuperview() - } - - bufferSlider.setThumbImage(UIImage(), for: .normal) - } - - func addLabels() { - let labelFontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 12) - currentTimeLabel.text = "00:00" - currentTimeLabel.textAlignment = .left - currentTimeLabel.font = UIFont.systemFont(ofSize: labelFontSize) - - timeLeftLabel.text = "00:00" - timeLeftLabel.textAlignment = .right - timeLeftLabel.adjustsFontSizeToFitWidth = true - timeLeftLabel.font = UIFont.systemFont(ofSize: labelFontSize) - - self.containerView.addSubview(currentTimeLabel) - self.containerView.addSubview(timeLeftLabel) - - currentTimeLabel.snp.makeConstraints { (make) -> Void in - make.left.equalTo(playbackSlider).inset(UIView.getValueScaledByScreenWidthFor(baseValue: 5)) - make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) - make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) - } - - timeLeftLabel.snp.makeConstraints { (make) -> Void in - make.right.equalTo(playbackSlider).inset(UIView.getValueScaledByScreenWidthFor(baseValue: 5)) - make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) - make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) - make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) - } - } - - @objc func playbackSliderValueChanged(_ slider: UISlider) { - let timeInSeconds = slider.value - - if (playbackSlider.isTracking) && (timeInSeconds != previousSliderValue) { - // Update Labels - // Do this without using functions because this views controller use the functions and they have a !isTracking guard - //@TODO: Figure out how to fix not being able to use functions -// self.updateSlider(currentValue: timeInSeconds) - playbackSlider.value = timeInSeconds - let duration = playbackSlider.maximumValue - let timeLeft = Float(duration - timeInSeconds) - - let currentTimeString = Helpers.createTimeString(time: timeInSeconds) - let timeLeftString = Helpers.createTimeString(time: timeLeft) -// self.updateTimeLabels(currentTimeText: currentTimeString, timeLeftText: timeLeftString) - self.currentTimeLabel.text = currentTimeString - self.timeLeftLabel.text = timeLeftString - } else { - self.delegate?.playbackSliderValueChanged(value: timeInSeconds) - let duration = playbackSlider.maximumValue - let timeLeft = Float(duration - timeInSeconds) - let currentTimeString = Helpers.createTimeString(time: timeInSeconds) - let timeLeftString = Helpers.createTimeString(time: timeLeft) - self.currentTimeLabel.text = currentTimeString - self.timeLeftLabel.text = timeLeftString - } - previousSliderValue = timeInSeconds - } - - func updateSlider(maxValue: Float) { - // Update max only once - guard playbackSlider.maximumValue <= 1.0 else { return } - - if playbackSlider.isUserInteractionEnabled == false { - playbackSlider.isUserInteractionEnabled = true - } - - playbackSlider.maximumValue = maxValue - bufferSlider.maximumValue = maxValue - } - - func updateSlider(currentValue: Float) { - // Have to check is first load because current value may be far from 0.0 - //@TODO: Fix this logic to fix jumping of playbackslider - guard !playbackSlider.isTracking else { return } -// if isFirstLoad { -// playbackSlider.value = currentValue -// isFirstLoad = false -// return +//import UIKit +//import AVFoundation +//import SwifterSwift +//import KTResponsiveUI +// +////enum PlaybackSpeed: Float { +//// case _1x = 1.0 +//// case _1_2x = 1.2 +//// case _1_4x = 1.4 +//// case _1_6x = 1.6 +//// case _1_8x = 1.8 +//// case _2x = 2.0 +//// case _2_5x = 2.5 +//// case _3x = 3.0 +//// +//// var title: String { +//// switch self { +//// case ._1x: +//// return "1x (Normal Speed)" +//// case ._1_2x: +//// return "1.2x" +//// case ._1_4x: +//// return "1.4x" +//// case ._1_6x: +//// return "1.6x" +//// case ._1_8x: +//// return "1.8x" +//// case ._2x: +//// return "⏩ 2x ⏩" +//// case ._2_5x: +//// return "2.5x" +//// case ._3x: +//// return "🔥 3x 🔥" +//// } +//// } +//// +//// var shortTitle: String { +//// switch self { +//// case ._1x: +//// return "1x" +//// case ._1_2x: +//// return "1.2x" +//// case ._1_4x: +//// return "1.4x" +//// case ._1_6x: +//// return "1.6x" +//// case ._1_8x: +//// return "1.8x" +//// case ._2x: +//// return "2x" +//// case ._2_5x: +//// return "2.5x" +//// case ._3x: +//// return "3x" +//// } +//// } +////} +// +////public protocol AudioViewDelegate: NSObjectProtocol { +//// func playButtonPressed() +//// func pauseButtonPressed() +//// func stopButtonPressed() +//// func skipForwardButtonPressed() +//// func skipBackwardButtonPressed() +//// func expandButtonPressed() +//// func collapseButtonPressed() +//// func audioRateChanged(newRate: Float) +//// func playbackSliderValueChanged(value: Float) +////} +// +//class AudioView: UIView { +// private var isCollapsed = true +// private weak var audioViewDelegate: AudioViewDelegate? +// private var activityView: UIActivityIndicatorView! +// private var podcastLabel = UILabel() +// private var expandCollapseButton = UIButton() +// private var skipForwardButton = UIButton() +// private var skipBackwardbutton = UIButton() +// private var bufferSlider = UISlider(frame: .zero) +// private var bufferBackgroundSlider = UISlider(frame: .zero) +// private var playbackSlider = UISlider(frame: .zero) +// private var currentTimeLabel = UILabel() +// private var timeLeftLabel = UILabel() +// private var previousSliderValue: Float = 0.0 +// private var playbackSpeedButton = UIButton() +// private var originalFrame: CGRect +// private var viewModel: PodcastViewModel? +// +// var isFirstLoad = true +// var playButton = UIButton() +// var pauseButton = UIButton() +// var stopButton = UIButton() +// +// var currentSpeed: PlaybackSpeed = ._1x { +// willSet { +// guard currentSpeed != newValue else { return } +// self.playbackSpeedButton.setTitle(newValue.shortTitle, for: .normal) +// self.audioViewDelegate?.audioRateChanged(newRate: newValue.rawValue) +// } +// } +// +// var alertController: UIAlertController! { +// let alert = UIAlertController(title: "Change Playback Speed", message: "Current Speed: \(self.currentSpeed.shortTitle)", preferredStyle: .actionSheet) +// let times: [PlaybackSpeed] = [._1x, ._1_2x, ._1_4x, ._1_6x, ._1_8x, ._2x, ._2_5x, ._3x] +// +// times.forEach({ (time) in +// let title = time.title +// alert.addAction(UIAlertAction(title: title, style: .default) { _ in +// self.currentSpeed = time +// }) +// }) +// alert.addAction(title: "Cancel", style: .cancel, isEnabled: true) {_ in +// self.alertController.dismiss(animated: true, completion: nil) +// } +// return alert +// } +// +// init(frame: CGRect, audioViewDelegate: AudioViewDelegate) { +// self.audioViewDelegate = audioViewDelegate +// self.originalFrame = frame +// super.init(frame: frame) +// +// self.performLayout() +// self.disableButtons() +// +// self.hideSliders() +// self.addShadow(location: .top, color: Stylesheet.Colors.grey, opacity: 0.8, radius: 5.0) +// } +// +// required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented"); } +// +// internal override func performLayout() { +// let containerView = UIView() +// self.addSubview(containerView) +// self.createAudioControlView(parentView: containerView) +// +// containerView.snp.makeConstraints { (make) in +// make.edges.equalToSuperview() +// } +// } +// +// func startActivityAnimating() { +// self.activityView.startAnimating() +// } +// +// func stopActivityAnimating() { +// self.activityView.stopAnimating() +// } +// +// func createAudioControlView(parentView: UIView) { +// let containerView = UIView() +// containerView.backgroundColor = .white +// parentView.addSubview(containerView) +// +// containerView.snp.makeConstraints { (make) -> Void in +// make.edges.equalToSuperview() +// } +// +// containerView.addSubview(podcastLabel) +// +// addPlaybackSlider(parentView: parentView) +// +// podcastLabel.snp.makeConstraints { (make) -> Void in +// make.left.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 60)) +// make.centerX.equalToSuperview() +// make.top.equalTo(playbackSlider.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) //changed +// } +// +// podcastLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) +// +// podcastLabel.numberOfLines = 2 +// podcastLabel.lineBreakMode = .byTruncatingTail +// podcastLabel.textAlignment = .center +// +// let stackView = UIStackView() +// containerView.addSubview(stackView) +// +// stackView.axis = .horizontal +// stackView.alignment = .fill +// stackView.distribution = .fillEqually +// +// stackView.snp.makeConstraints { (make) -> Void in +// //make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 70)) +// make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: (50 * 5))) +// make.top.equalTo(podcastLabel.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 20)) +// make.centerX.equalToSuperview() +// //make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 20)) //changed +// } +// +// //stackView.addArrangedSubview(expandCollapseButton) +// stackView.addArrangedSubview(skipBackwardbutton) +// stackView.addArrangedSubview(playButton) +// stackView.addArrangedSubview(pauseButton) +// stackView.addArrangedSubview(skipForwardButton) +// //stackView.addArrangedSubview(playbackSpeedButton) +// +// +// let iconHeight = UIView.getValueScaledByScreenWidthFor(baseValue: (70 / 2)) +// +// expandCollapseButton.setIcon(icon: .fontAwesome(.angleUp), iconSize: 30.0, color: Stylesheet.Colors.base, forState: .normal) +// +// skipBackwardbutton.setImage(UIImage(named: "rewind_audio"), for: .normal) +// skipBackwardbutton.height = iconHeight +// skipBackwardbutton.tintColor = Stylesheet.Colors.base +// +// playButton.setImage(UIImage(named: "play_audio"), for: .normal) +// pauseButton.setImage(UIImage(named: "pause_audio"), for: .normal) +// stopButton.setIcon(icon: .fontAwesome(.close), iconSize: iconHeight, color: Stylesheet.Colors.base, forState: .normal) +// +// skipForwardButton.setImage(UIImage(named: "forward_audio"), for: .normal) +// skipForwardButton.height = iconHeight +// skipForwardButton.tintColor = Stylesheet.Colors.base +// +// expandCollapseButton.addTarget(self, action: #selector(self.expandButtonPressed), for: .touchUpInside) +// skipBackwardbutton.addTarget(self, action: #selector(self.skipBackwardButtonPressed), for: .touchUpInside) +// playButton.addTarget(self, action: #selector(self.playButtonPressed), for: .touchUpInside) +// pauseButton.addTarget(self, action: #selector(self.pauseButtonPressed), for: .touchUpInside) +// stopButton.addTarget(self, action: #selector(self.stopButtonPressed), for: .touchUpInside) +// skipForwardButton.addTarget(self, action: #selector(self.skipForwardButtonPressed), for: .touchUpInside) +// +// playButton.isHidden = true +// +// playbackSpeedButton.titleLabel?.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 20)) +// playbackSpeedButton.setTitle(PlaybackSpeed._1x.shortTitle, for: .normal) +// playbackSpeedButton.setTitleColor(Stylesheet.Colors.base, for: .normal) +// playbackSpeedButton.addTarget(self, action: #selector(self.settingsButtonPressed), for: .touchUpInside) +// parentView.addSubview(playbackSpeedButton) +// +// let width = UIView.getValueScaledByScreenWidthFor(baseValue: 40) +// let height = UIView.getValueScaledByScreenHeightFor(baseValue: 40) +// playbackSpeedButton.snp.makeConstraints { (make) -> Void in +// make.width.equalTo(width) +// make.height.equalTo(height) +// make.centerY.equalTo(stackView.snp_centerY) +// make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) +// } +// +// setupActivityIndicator(parentView: containerView) +// //changed addPlaybackSlider(parentView: parentView) +// addLabels(parentView: containerView) +// +// parentView.addSubview(self.expandCollapseButton) +//// +// self.expandCollapseButton.snp.makeConstraints { (make) in +// //make.bottom.left.equalToSuperview() +// //make.width.height.equalTo(iconHeight) +// make.centerY.equalTo(stackView.snp_centerY) +// make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) // } -// -// let min = playbackSlider.value - 60.0 -// let max = playbackSlider.value + 60.0 - - // Check if current value is within a close enough range to slider value - // This fixes sliders skipping around -// if min...max ~= currentValue && !playbackSlider.isTracking { - playbackSlider.value = currentValue +// } +// +// func addPlaybackSlider(parentView: UIView) { +// addBufferSlider(parentView: parentView) +// +// playbackSlider.minimumValue = 0 +// playbackSlider.isContinuous = true +// playbackSlider.minimumTrackTintColor = Stylesheet.Colors.base +// playbackSlider.maximumTrackTintColor = .clear +// playbackSlider.layer.cornerRadius = 0 +// playbackSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) +// playbackSlider.isUserInteractionEnabled = false +// +// parentView.addSubview(playbackSlider) +// self.bringSubview(toFront: playbackSlider) +// +// playbackSlider.snp.makeConstraints { (make) -> Void in +// make.top.equalToSuperview().offset(5) +// make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) +// make.left.right.equalToSuperview().inset(15) +// // } - } - - func updateBufferSlider(bufferValue: Float) { - bufferSlider.value = bufferValue - } - - func updateTimeLabels(currentTimeText: String, timeLeftText: String) { - guard !playbackSlider.isTracking else { return } - self.currentTimeLabel.text = currentTimeText - self.timeLeftLabel.text = timeLeftText - } - - public func animateIn() { - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { - self.frame.origin.y -= self.height - self.frame = self.frame - }) - } - - public func animateOut() { - UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { - self.frame.origin.y += self.height - self.frame = self.frame - }, completion: { _ in - self.removeFromSuperview() - }) - } - - public func setText(text: String?) { - podcastLabel.text = text ?? "" - } - - func setupActivityIndicator() { - activityView = UIActivityIndicatorView(activityIndicatorStyle: .gray) - self.containerView.addSubview(activityView) - - activityView.snp.makeConstraints { (make) -> Void in - make.centerY.equalToSuperview() - make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) - } - } - - func enableButtons() { - log.warning("enabling buttons") - self.playButton.isEnabled = true - self.pauseButton.isEnabled = true - self.stopButton.isEnabled = true - self.skipForwardButton.isEnabled = true - self.skipBackwardbutton.isEnabled = true - } - - func disableButtons() { - log.warning("disabling buttons") - self.playButton.isEnabled = false - self.pauseButton.isEnabled = false - self.stopButton.isEnabled = false - self.skipForwardButton.isEnabled = false - self.skipBackwardbutton.isEnabled = false - } -} - - -extension AudioView { - // MARK: Function - @objc func playButtonPressed() { - delegate?.playButtonPressed() - } - - @objc func pauseButtonPressed() { - delegate?.pauseButtonPressed() - } - - @objc func stopButtonPressed() { - delegate?.stopButtonPressed() - } - - @objc func skipForwardButtonPressed() { - delegate?.skipForwardButtonPressed() - } - - @objc func skipBackwardButtonPressed() { - delegate?.skipBackwardButtonPressed() - } - - @objc func settingsButtonPressed() { - // Present alert view - self.parentViewController?.present(alertController, animated: true, completion: nil) - } -} +// +// let smallCircle = #imageLiteral(resourceName: "SmallCircle").filled(withColor: Stylesheet.Colors.base) +// playbackSlider.setThumbImage(smallCircle, for: .normal) +// +// let bigCircle = #imageLiteral(resourceName: "BigCircle").filled(withColor: Stylesheet.Colors.base) +// playbackSlider.setThumbImage(bigCircle, for: .highlighted) +// } +// +// func addBufferSlider(parentView: UIView) { +// bufferBackgroundSlider.minimumValue = 0 +// bufferBackgroundSlider.isContinuous = true +// bufferBackgroundSlider.tintColor = Stylesheet.Colors.bufferColor +// bufferBackgroundSlider.layer.cornerRadius = 0 +// bufferBackgroundSlider.alpha = 0.5 +// bufferBackgroundSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) +// bufferBackgroundSlider.isUserInteractionEnabled = false +// +// parentView.addSubview(bufferBackgroundSlider) +// +// bufferBackgroundSlider.snp.makeConstraints { (make) -> Void in +// make.top.equalToSuperview().inset(5) +// make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) +// make.left.right.equalToSuperview().inset(15) +// } +// +// bufferBackgroundSlider.setThumbImage(UIImage(), for: .normal) +// +// bufferSlider.minimumValue = 0 +// bufferSlider.isContinuous = true +// bufferSlider.minimumTrackTintColor = Stylesheet.Colors.bufferColor +// bufferSlider.maximumTrackTintColor = .clear +// bufferSlider.layer.cornerRadius = 0 +// bufferSlider.addTarget(self, action: #selector(self.playbackSliderValueChanged(_:)), for: .valueChanged) +// bufferSlider.isUserInteractionEnabled = false +// +// parentView.addSubview(bufferSlider) +// +// bufferSlider.snp.makeConstraints { (make) -> Void in +// make.top.equalToSuperview().inset(5) +// make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) +// make.left.right.equalToSuperview().inset(15) +// } +// +// bufferSlider.setThumbImage(UIImage(), for: .normal) +// } +// +// func addLabels(parentView: UIView) { +// let labelFontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 12) +// currentTimeLabel.text = "00:00" +// currentTimeLabel.textAlignment = .left +// currentTimeLabel.font = UIFont.systemFont(ofSize: labelFontSize) +// +// timeLeftLabel.text = "00:00" +// timeLeftLabel.textAlignment = .right +// timeLeftLabel.adjustsFontSizeToFitWidth = true +// timeLeftLabel.font = UIFont.systemFont(ofSize: labelFontSize) +// +// parentView.addSubview(currentTimeLabel) +// parentView.addSubview(timeLeftLabel) +// +// currentTimeLabel.snp.makeConstraints { (make) -> Void in +// make.left.equalTo(playbackSlider) +// make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) +// make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) +// make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) +// } +// +// timeLeftLabel.snp.makeConstraints { (make) -> Void in +// make.right.equalTo(playbackSlider) +// make.top.equalTo(playbackSlider.snp.bottom).inset(UIView.getValueScaledByScreenHeightFor(baseValue: 5)) +// make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 20)) +// make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 55)) +// } +// } +// //MARK: Slider value changed implementation +// +// @objc func playbackSliderValueChanged(_ slider: UISlider) { +// let timeInSeconds = slider.value +// if (playbackSlider.isTracking) && (timeInSeconds != previousSliderValue) { +// playbackSlider.value = timeInSeconds +// let duration = playbackSlider.maximumValue +// let timeLeft = Float(duration - timeInSeconds) +// +// let currentTimeString = Helpers.createTimeString(time: timeInSeconds, units: [.minute, .second]) +// let timeLeftString = Helpers.createTimeString(time: timeLeft, units: [.minute, .second]) +// self.currentTimeLabel.text = currentTimeString +// self.timeLeftLabel.text = timeLeftString +// } else { +// self.audioViewDelegate?.playbackSliderValueChanged(value: timeInSeconds) +// let duration = playbackSlider.maximumValue +// let timeLeft = Float(duration - timeInSeconds) +// let currentTimeString = Helpers.createTimeString(time: timeInSeconds, units: [.minute, .second]) +// let timeLeftString = Helpers.createTimeString(time: timeLeft, units: [.minute, .second]) +// self.currentTimeLabel.text = currentTimeString +// self.timeLeftLabel.text = timeLeftString +// } +// previousSliderValue = timeInSeconds +// } +// +// func updateSlider(maxValue: Float) { +// guard playbackSlider.maximumValue >= 0.0 else { return } +// +// if playbackSlider.isUserInteractionEnabled == false { +// playbackSlider.isUserInteractionEnabled = true +// } +// +// playbackSlider.maximumValue = maxValue +// bufferSlider.maximumValue = maxValue +// } +// +// func updateSlider(currentValue: Float) { +// guard !playbackSlider.isTracking else { return } +// playbackSlider.value = currentValue +// } +// +// func updateBufferSlider(bufferValue: Float) { +// bufferSlider.value = bufferValue +// } +// +// func updateTimeLabels(currentTimeText: String, timeLeftText: String) { +// guard !playbackSlider.isTracking else { return } +// self.currentTimeLabel.text = currentTimeText +// self.timeLeftLabel.text = timeLeftText +// } +// +// func setText(text: String?) { +// podcastLabel.text = text ?? "" +// } +// +// func setupActivityIndicator(parentView: UIView) { +// activityView = UIActivityIndicatorView(activityIndicatorStyle: .gray) +// parentView.addSubview(activityView) +// +// activityView.snp.makeConstraints { (make) -> Void in +// make.centerX.equalToSuperview() +// make.top.equalTo(playButton.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) +// } +// } +// +// func enableButtons() { +// log.warning("enabling buttons") +// self.playButton.isEnabled = true +// self.pauseButton.isEnabled = true +// self.stopButton.isEnabled = true +// self.skipForwardButton.isEnabled = true +// self.skipBackwardbutton.isEnabled = true +// } +// +// func disableButtons() { +// log.warning("disabling buttons") +// self.playButton.isEnabled = false +// self.pauseButton.isEnabled = false +// self.stopButton.isEnabled = false +// self.skipForwardButton.isEnabled = false +// self.skipBackwardbutton.isEnabled = false +// } +// +// private func toggleExpandCollapse() { +// self.isCollapsed = !self.isCollapsed +// let rotationAngle: CGFloat = self.isCollapsed ? -179 : 180 +// self.expandCollapseButton.rotate( +// byAngle: rotationAngle, +// ofType: .degrees, +// animated: true, +// duration: 0.25, +// completion: { isFinished in +// if isFinished && self.isCollapsed { +// self.expandCollapseButton.transform = .identity +// } +// }) +// } +// +// func hideSliders() { +// self.bufferSlider.isHidden = true +// self.playbackSlider.isHidden = true +// self.bufferBackgroundSlider.isHidden = true +// } +// +// func showSliders() { +// self.bufferSlider.isHidden = false +// self.playbackSlider.isHidden = false +// self.bufferBackgroundSlider.isHidden = false +// } +// +// func showExpandCollapseButton() { +// self.expandCollapseButton.isHidden = false +// } +// +// func hideExpandCollapseButton() { +// self.expandCollapseButton.isHidden = true +// } +//} +// +//extension AudioView { +// @objc func playButtonPressed() { +// self.audioViewDelegate?.playButtonPressed() +// } +// +// @objc func pauseButtonPressed() { +// self.audioViewDelegate?.pauseButtonPressed() +// } +// +// @objc func stopButtonPressed() { +// self.audioViewDelegate?.stopButtonPressed() +// } +// +// @objc func skipForwardButtonPressed() { +// self.audioViewDelegate?.skipForwardButtonPressed() +// } +// +// @objc func skipBackwardButtonPressed() { +// self.audioViewDelegate?.skipBackwardButtonPressed() +// } +// +// @objc func expandButtonPressed() { +// self.toggleExpandCollapse() +// +// if self.isCollapsed { +// self.audioViewDelegate?.collapseButtonPressed() +// } else { +// self.audioViewDelegate?.expandButtonPressed() +// } +// } +// +// @objc func settingsButtonPressed() { +// self.parentViewController?.present(alertController, animated: true, completion: nil) +// } +//} +// +// +//enum VerticalLocation: String { +// case bottom +// case top +//} +// +//extension UIView { +// func addShadow(location: VerticalLocation, color: UIColor = .black, opacity: Float = 0.5, radius: CGFloat = 5.0) { +// switch location { +// case .bottom: +// addShadow(offset: CGSize(width: 0, height: 5), color: color, opacity: opacity, radius: radius) +// case .top: +// addShadow(offset: CGSize(width: 0, height: 5), color: color, opacity: opacity, radius: radius) +// } +// } +// +// func addShadow(offset: CGSize, color: UIColor = .black, opacity: Float = 0.5, radius: CGFloat = 5.0) { +// self.layer.masksToBounds = false +// self.layer.shadowColor = color.cgColor +// self.layer.shadowOffset = offset +// self.layer.shadowOpacity = opacity +// self.layer.shadowRadius = radius +// } +//} diff --git a/SEDaily-IOS/AudioViewManager.swift b/SEDaily-IOS/AudioViewManager.swift deleted file mode 100644 index f6e053e..0000000 --- a/SEDaily-IOS/AudioViewManager.swift +++ /dev/null @@ -1,255 +0,0 @@ -// -// AudioViewManager.swift -// SEDaily-IOS -// -// Created by Craig Holliday on 6/29/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -import UIKit -import SwiftIcons -import AVFoundation -import SnapKit -import SwifterSwift -import KTResponsiveUI -import KoalaTeaPlayer - -class AudioViewManager: NSObject { - - static let shared: AudioViewManager = AudioViewManager() - private override init() {} - - /// The instance of `AssetPlaybackManager` that the app uses for managing playback. - var assetPlaybackManager: AssetPlayer! = nil - - /// The instance of `RemoteCommandManager` that the app uses for managing remote command events. - var remoteCommandManager: RemoteCommandManager! = nil - - var audioView: AudioView? - var podcastModel: PodcastViewModel? - - func setupManager(podcastModel: PodcastViewModel) { - self.podcastModel = podcastModel - Tracker.logPlayPodcast(podcast: podcastModel) - self.presentAudioView() - } - - fileprivate func setupAudioManager(url: URL, name: String) { - var savedTime: Float = 0 - //@TODO: Add tracking for current time again -// if let time = podcastModel?.currentTime { -// if let float = Float(time) { -// savedTime = float -// } -// } - log.info(savedTime, "savedtime") - - let asset = Asset(assetName: name, url: url, savedTime: savedTime) - assetPlaybackManager = AssetPlayer(asset: asset) - assetPlaybackManager.playerDelegate = self - - // If you want remote commands - // Initializer the `RemoteCommandManager`. - remoteCommandManager = RemoteCommandManager(assetPlaybackManager: assetPlaybackManager) - - // Always enable playback commands in MPRemoteCommandCenter. - remoteCommandManager.activatePlaybackCommands(true) - remoteCommandManager.toggleChangePlaybackPositionCommand(true) - remoteCommandManager.toggleSkipBackwardCommand(true, interval: 30) - remoteCommandManager.toggleSkipForwardCommand(true, interval: 30) - remoteCommandManager.toggleChangePlaybackPositionCommand(true) - } - - fileprivate func presentAudioView() { - if var topController = UIApplication.shared.keyWindow?.rootViewController { - while let presentedViewController = topController.presentedViewController { - topController = presentedViewController - } - - guard !(topController is UIAlertController) else { - // There's already a alert preseneted - return - } - - self.setupView(over: topController) - - // Move top controller's view's bottom constraint - if let controller = topController as? ContainerViewController { - controller.setContainerViewInset() - } - - guard let url = self.podcastModel?.mp3URL else { return } - guard let name = self.podcastModel?.podcastTitle else { return } - - self.setupAudioManager(url: url, name: name) - } - } - - fileprivate func triggerRemoveContainerViewInset() { - if var topController = UIApplication.shared.keyWindow?.rootViewController { - while let presentedViewController = topController.presentedViewController { - topController = presentedViewController - } - - guard !(topController is UIAlertController) else { - // There's already a alert preseneted - return - } - - // Move top controller's view's bottom constraint - if let controller = topController as? ContainerViewController { - controller.removeContainerViewInset() - } - } - } - - fileprivate func setupView(over vc: UIViewController) { - if audioView != nil { - // Setup progress, text, other stuff - setText(text: podcastModel?.podcastTitle) - return - } - - audioView = AudioView() - audioView?.delegate = self - - // Can't add to view - vc.view.addSubview(audioView!) - - audioView?.width = UIScreen.main.bounds.width - audioView?.height = UIView.getValueScaledByScreenHeightFor(baseValue: 110) - audioView?.center.x = vc.view.center.x - audioView?.frame.origin.y = UIScreen.main.bounds.height - - setText(text: podcastModel?.podcastTitle) - - audioView?.animateIn() - } - - fileprivate func setText(text: String?) { - guard audioView != nil else { return } - audioView?.setText(text: text) - } - - //@TODO: Switch all handling of enabled parts of audio view to here - //@TODO: Add manager param and update everything here (maybe) - fileprivate func handleStateChange(for state: AssetPlayerPlaybackState) { - if let model = podcastModel { - self.setText(text: model.podcastTitle) - } - - switch state { - case .setup: - audioView?.isFirstLoad = true - audioView?.disableButtons() - audioView?.activityView.startAnimating() - - audioView?.playButton.isHidden = false - audioView?.pauseButton.isHidden = true - break - case .playing: - audioView?.enableButtons() - audioView?.activityView.stopAnimating() - - audioView?.playButton.isHidden = true - audioView?.pauseButton.isHidden = false - break - case .paused: - audioView?.activityView.stopAnimating() - - audioView?.playButton.isHidden = false - audioView?.pauseButton.isHidden = true - break - case .interrupted: - //@TODO: handle interrupted - break - case .failed: - audioView?.animateOut() - break - case .buffering: - audioView?.activityView.startAnimating() - - audioView?.stopButton.isEnabled = true - audioView?.playButton.isHidden = false - audioView?.pauseButton.isHidden = true - break - case .stopped: - self.triggerRemoveContainerViewInset() - audioView?.animateOut() - - audioView = nil - break - } - } -} - -extension AudioViewManager: AssetPlayerDelegate { - func currentAssetDidChange(_ player: AssetPlayer) { - log.debug("asset did change") - audioView?.currentSpeed = ._1x - } - - func playerIsSetup(_ player: AssetPlayer) { - audioView?.updateSlider(maxValue: player.maxSecondValue) - } - - func playerPlaybackStateDidChange(_ player: AssetPlayer) { - guard let state = player.state else { return } - self.handleStateChange(for: state) - } - - func playerCurrentTimeDidChange(_ player: AssetPlayer) { - //@TODO: Add back current time tracking -// podcastModel?.update(currentTime: Float(player.currentTime)) - - audioView?.updateTimeLabels(currentTimeText: player.timeElapsedText, timeLeftText: player.timeLeftText) - - audioView?.updateSlider(currentValue: Float(player.currentTime)) - } - - func playerPlaybackDidEnd(_ player: AssetPlayer) { - //@TODO: Add back current time tracking -// podcastModel?.update(currentTime: 0.0) - } - - func playerIsLikelyToKeepUp(_ player: AssetPlayer) { - //@TODO: Nothing to do here? - } - - func playerBufferTimeDidChange(_ player: AssetPlayer) { - audioView?.updateBufferSlider(bufferValue: player.bufferedTime) - } - -} - -extension AudioViewManager: AudioViewDelegate { - func playbackSliderValueChanged(value: Float) { - let cmTime = CMTimeMake(Int64(value), 1) - assetPlaybackManager?.seekTo(cmTime) - } - - func playButtonPressed() { - assetPlaybackManager?.play() - } - - func pauseButtonPressed() { - assetPlaybackManager?.pause() - } - - func stopButtonPressed() { - assetPlaybackManager?.stop() - } - - func skipForwardButtonPressed() { - assetPlaybackManager?.skipForward(30) - } - - func skipBackwardButtonPressed() { - assetPlaybackManager?.skipBackward(30) - } - - func audioRateChanged(newRate: Float) { - // Change audio player speed - assetPlaybackManager?.changePlayerPlaybackRate(to: newRate) - } -} diff --git a/SEDaily-IOS/Author.swift b/SEDaily-IOS/Author.swift new file mode 100644 index 0000000..7f62bab --- /dev/null +++ b/SEDaily-IOS/Author.swift @@ -0,0 +1,33 @@ +// +// Author.swift +// SEDaily-IOS +// +// Created by jason on 2/5/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation +// TODO: should merge this with User constuct? + +public struct Author: Codable { + let email: String? + let username: String? + let name: String? + let avatarUrl: String? + let _id: String? +} + + +extension Author { + func displayName() -> String { + + if let name = self.name { + return name + } + if let username = self.username { + return username + } + + return L10n.anonymous + } +} diff --git a/SEDaily-IOS/AvatarCell.swift b/SEDaily-IOS/AvatarCell.swift new file mode 100644 index 0000000..b058860 --- /dev/null +++ b/SEDaily-IOS/AvatarCell.swift @@ -0,0 +1,52 @@ +// +// AvatarCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/5/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + + +import UIKit +import Reusable + +class AvatarCell: UITableViewCell, Reusable { + private var avatarImageView: UIImageView! + + var avatarURL: URL? = nil { + didSet { + setupLayout() + setupAvatar(imageURL: avatarURL) + } + } +} + +extension AvatarCell { + private func setupLayout() { + self.selectionStyle = .none + + avatarImageView = UIImageView() + contentView.addSubview(avatarImageView) + avatarImageView.contentMode = .scaleAspectFill + avatarImageView.clipsToBounds = true + avatarImageView.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 50) + avatarImageView.kf.indicatorType = .activity + + avatarImageView.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 100.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 100.0)) + make.centerX.equalToSuperview() + } + } + private func setupAvatar(imageURL: URL?) { + avatarImageView.kf.cancelDownloadTask() + avatarImageView.image = nil + guard let imageURL = imageURL else { + avatarImageView.image = #imageLiteral(resourceName: "SEDaily_Logo") + return + } + avatarImageView.kf.setImage(with: imageURL, options: [.transition(.fade(0.2))]) + } +} diff --git a/SEDaily-IOS/Base.lproj/LaunchScreen.storyboard b/SEDaily-IOS/Base.lproj/LaunchScreen.storyboard index fdf3f97..d62e194 100644 --- a/SEDaily-IOS/Base.lproj/LaunchScreen.storyboard +++ b/SEDaily-IOS/Base.lproj/LaunchScreen.storyboard @@ -1,7 +1,11 @@ - - + + + + + - + + @@ -14,14 +18,31 @@ - + + + + + + + + + + + + + + + - + + + + diff --git a/SEDaily-IOS/Base.lproj/Localizable.strings b/SEDaily-IOS/Base.lproj/Localizable.strings index e370503..81d3109 100644 --- a/SEDaily-IOS/Base.lproj/Localizable.strings +++ b/SEDaily-IOS/Base.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings SEDaily-IOS @@ -7,6 +7,13 @@ */ /* */ +"EnthusiasticHello" = "Hello!"; +"EnthusiasticYes" = "Yes!"; +"EnthusiasticSureSendEmail" = "Sure! Send email"; +"AppReviewPromptQuestion" = "Enjoying the SEDaily app?"; +"AppReviewEmailSubject" = "SEDaily iOS App Feedback"; +"AppReviewApology" = "Oh, sorry you're not liking the SEDaily app"; +"AppReviewGiveFeedbackQuestion" = "Would you help us out by sending us your feedback?"; "TabBarTitleLatest" = "Latest"; "TabBarJustForYou" = "Just For You"; "TabTitleAll" = "All"; @@ -22,7 +29,11 @@ "TabTitleGreatestHits" = "Greatest Hits"; "LoginTitle" = "Login"; "LogoutTitle" = "Logout"; +"UsernameOrEmailPlaceholder" = "Username or Email"; "EmailAddressPlaceholder" = "Email"; +"EmailUnsupportedOnDevice" = "Email unsupported on this device"; +"EmailUnsupportedMessage" = "Please send an email to jeff@softwareengineeringdaily.com"; +"UsernamePlaceholder" = "Username"; "PasswordPlaceholder" = "Password"; "ConfirmPasswordPlaceholder" = "Confirm Password"; "FirstNamePlaceholder" = "First Name"; @@ -31,6 +42,7 @@ "CancelButtonTitle" = "Cancel"; "SignUpButtonTitle" = "Sign Up"; "AlertMessageEmailEmpty" = "Email Field Empty"; +"AlertMessageUsernameEmpty" = "Username Field Empty"; "AlertMessagePasswordEmpty" = "Password Field Empty"; "AlertMessagePasswordConfirmEmpty" = "Confirm Password Field Empty"; "AlertMessageEmailWrongFormat" = "Invalid Email Format"; @@ -48,4 +60,47 @@ "GenericError" = "Error"; "GenericOkay" = "Okay"; "GenericOK" = "OK"; +"GenericNo" = "No"; +"NoWithGratitude" = "No thanks"; "Play" = "Play"; +"RelatedLinks" = "Related Links"; +"NoBookmarks" = "No saved episodes."; +"LoginSeeBookmarks" = "Login to see your saved episodes"; +"TapToRefresh" = "Tap to refresh"; +"Comments" = "Comments"; +"ThereWasAProblem" = "There was a problem :("; +"SucccessfullySubmitted" = "Successfully submitted :)"; +"Submitting" = "Submitting..."; +"Anonymous" = "Anonymous"; +"TabBarNotifications" = "Notifications"; +"TabBarForum" = "Forum"; +"TabBarFeed" = "Feed"; +"TabBarSaved" = "Saved"; +"TabBarDownloads" = "Downloads"; +"mwfNotificationTitle" = "Software Daily"; +"mwfNotificationBody" = "We have new episodes for you!"; +"Search" = "Search"; +"EmptySearch" = "No results for search"; +"FetchingSearch" = "Fetching results"; +"ToggleToSignUpButtonTitle" = "no account? sign up"; +"ToggleToSignInButtonTitle" = "back to sign in"; +"SignUpHeader" = "Sign Up"; +"SignInHeader" = "Sign In"; +"TimeLeft" = "minutes left"; +"Transcript" = "Transcript"; +"FieldEmpty" = "Fields cannot be blank, please fill and retry"; +"SomethingWentWrong" = "Something went wrong, please try again"; +"NewLink" = "Add new link"; +"AddLink" = "Add a link"; +"Submit" = "Submit"; +"ShortTitle" = "Add a short title"; +"NoDownloads" = "No downloaded episodes"; +"CommentsPlaceholder" = "Add a comment..."; +"DeletePodcast" = "Are you sure you want to delete this podcast?"; +"DeletePodcastButtonTitle" = "YEP! Delete it please."; +"CancelReplyButtonTitle" = "Cancel reply"; +"ReplyButtonTitle" = "Reply"; +"TypeHere" = "Type here"; +"Post" = "Post"; +"EnableNotifications" = "Enable Notifications"; +"EditProfile" = "Edit Profile"; diff --git a/SEDaily-IOS/Base.lproj/Main.storyboard b/SEDaily-IOS/Base.lproj/Main.storyboard index 791cd8e..b63d8a9 100644 --- a/SEDaily-IOS/Base.lproj/Main.storyboard +++ b/SEDaily-IOS/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + diff --git a/SEDaily-IOS/BaseFeedItem.swift b/SEDaily-IOS/BaseFeedItem.swift new file mode 100644 index 0000000..538aba5 --- /dev/null +++ b/SEDaily-IOS/BaseFeedItem.swift @@ -0,0 +1,16 @@ +// +// BaseFeedItem.swift +// SEDaily-IOS +// +// Created by jason on 5/18/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +protocol BaseFeedItem: Codable { + var _id: String {get set} + var score: Int {get set} + var downvoted: Bool? {get set} + var upvoted: Bool? {get set} +} diff --git a/SEDaily-IOS/Bookmark/BookmarkCollectionViewController.swift b/SEDaily-IOS/Bookmark/BookmarkCollectionViewController.swift new file mode 100644 index 0000000..a5d2ba9 --- /dev/null +++ b/SEDaily-IOS/Bookmark/BookmarkCollectionViewController.swift @@ -0,0 +1,265 @@ +// +// BookmarkCollectionViewController.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/4/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import UIKit +import StatefulViewController + + +/// Collection view controller for viewing all bookmarks for the user. +class BookmarkCollectionViewController: UICollectionViewController, StatefulViewController, MainCoordinated { + var mainCoordinator: MainFlowCoordinator? + + static private let cellId = "PodcastCellId" + private let reuseIdentifier = "Cell" + + private var viewModelController = BookmarkViewModelController() + + private var progressController = PlayProgressModelController() + + lazy var skeletonCollectionView: SkeletonCollectionView = { + return SkeletonCollectionView(frame: self.collectionView!.frame) + }() + + + override init(collectionViewLayout layout: UICollectionViewLayout) { + super.init(collectionViewLayout: layout) + + self.tabBarItem = UITabBarItem(title: L10n.tabBarSaved, image: UIImage(named: "bookmark_outline"), selectedImage: UIImage(named: "bookmark")) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + + self.collectionView?.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.loginObserver), + name: .loginChanged, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + + self.errorView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + self.errorView?.backgroundColor = .green + + + + let refreshControl = UIRefreshControl() + refreshControl.addTarget( + self, + action: #selector(pullToRefresh(_:)), + for: .valueChanged) + self.collectionView?.refreshControl = refreshControl + Analytics2.bookmarksPageViewed() + } + + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + @objc private func pullToRefresh(_ sender: Any) { + self.refreshView(useCache: false) + } + + func hasContent() -> Bool { + if UserManager.sharedInstance.getActiveUser().isLoggedIn() { + return self.viewModelController.viewModelsCount > 0 + } + return false + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.setupInitialViewState() + progressController.retrieve() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.refreshView(useCache: true) + } + + /// Refresh the view + /// + /// - Parameter useCache: true to use disk cache first while network calls occur in the background + private func refreshView(useCache: Bool) { + self.startLoading() + if UserManager.sharedInstance.getActiveUser().isLoggedIn() { + self.updateLoadingView(view: skeletonCollectionView) + self.updateEmptyView(view: + StateView( + frame: CGRect.zero, + text: L10n.noBookmarks, + showLoadingIndicator: false, + showRefreshButton: false, + delegate: self)) + + if useCache { + self.viewModelController.retrieveCachedBookmarkData(onSuccess: { + self.endLoading() + DispatchQueue.main.async { + self.collectionView?.reloadData() + self.collectionView?.refreshControl?.endRefreshing() + } + }) + } + self.viewModelController.retrieveNetworkBookmarkData { + self.endLoading() + DispatchQueue.main.async { + self.collectionView?.reloadData() + self.collectionView?.refreshControl?.endRefreshing() + } + } + } else { + self.updateLoadingView(view: + StateView( + frame: CGRect.zero, + text: "", + showLoadingIndicator: false, + showRefreshButton: false, + delegate: nil)) + self.updateEmptyView(view: + StateView( + frame: CGRect.zero, + text: L10n.loginSeeBookmarks, + showLoadingIndicator: false, + showRefreshButton: false, + delegate: nil)) + self.endLoading() + DispatchQueue.main.async { + self.collectionView?.reloadData() + self.collectionView?.refreshControl?.endRefreshing() + } + } + } + + private func updateLoadingView(view: UIView) { + self.loadingView?.removeFromSuperview() + self.loadingView = view + } + + private func updateEmptyView(view: UIView) { + self.emptyView?.removeFromSuperview() + self.emptyView = view + } + + @objc func loginObserver() { + self.refreshView(useCache: false) + } + + override func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + override func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int) -> Int { + return UserManager.sharedInstance.getActiveUser().isLoggedIn() ? + self.viewModelController.viewModelsCount : 0 + } + + override func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + + if let viewModel = self.viewModelController.viewModel(at: indexPath.row) { + cell.viewModel = viewModel + + let upvoteService = UpvoteService(podcastViewModel: viewModel) + let bookmarkService = BookmarkService(podcastViewModel: viewModel) + + cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) + + + cell.viewModel = viewModel + cell.upvoteService = upvoteService + cell.bookmarkService = bookmarkService + + cell.commentShowCallback = { [weak self] in + self?.commentsButtonPressed(viewModel) + + } + } + + return cell + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath) { + if let viewModel = viewModelController.viewModel(at: indexPath.row) { + + let vc = EpisodeViewController() + vc.viewModel = viewModel + mainCoordinator?.configure(viewController: vc) + self.navigationController?.pushViewController(vc, animated: true) + + } + } +} + +extension BookmarkCollectionViewController: StateViewDelegate { + func refreshPressed() { + self.refreshView(useCache: false) + Analytics2.refreshMyBookmarksPressed() + } +} + +extension BookmarkCollectionViewController{ + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + viewModelDidChange(viewModel: viewModel) + } + } + } +} + + +extension BookmarkCollectionViewController { + private func viewModelDidChange(viewModel: PodcastViewModel) { + self.viewModelController.update(with: viewModel) + } +} + + +extension BookmarkCollectionViewController { + func commentsButtonPressed(_ viewModel: PodcastViewModel) { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } +} + + diff --git a/SEDaily-IOS/Bookmark/BookmarkViewModelController.swift b/SEDaily-IOS/Bookmark/BookmarkViewModelController.swift new file mode 100644 index 0000000..dae5fe2 --- /dev/null +++ b/SEDaily-IOS/Bookmark/BookmarkViewModelController.swift @@ -0,0 +1,63 @@ +// +// BookmarkViewModelController.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/4/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +public class BookmarkViewModelController { + typealias Model = Podcast + typealias ViewModel = PodcastViewModel + typealias SuccessCallback = () -> Void + typealias ErrorCallback = (RepositoryError?) -> Void + + private let repository = PodcastRepository() + private var viewModels: [ViewModel?] = [] + + var viewModelsCount: Int { + return viewModels.count + } + + func viewModel(at index: Int) -> ViewModel? { + guard index >= 0 && index < viewModelsCount else { return nil } + return viewModels[index] + } + + func update(with podcast: PodcastViewModel) { + let index = self.viewModels.index { (item) -> Bool in + return item?._id == podcast._id + } + guard let modelsIndex = index else { return } + self.viewModels.remove(at: modelsIndex) + if podcast.isBookmarked { + self.viewModels.insert(podcast, at: modelsIndex) + } + + self.repository.updateDataSource(diskKey: .PodcastFolder, item: podcast.baseModelRepresentation) + } + + func retrieveCachedBookmarkData(onSuccess: @escaping SuccessCallback) { + self.repository.retrieveCachedBookmarkData( + onSuccess: { (podcasts) in + self.viewModels.removeAll() + podcasts.forEach({ podcast in + self.viewModels.push(ViewModel(podcast: podcast)) + }) + onSuccess() }, + onFailure: { _ in }) + } + + func retrieveNetworkBookmarkData(onSuccess: @escaping SuccessCallback) { + self.repository.retrieveNetworkBookmarkData( + onSuccess: { (podcasts) in + self.viewModels.removeAll() + podcasts.forEach({ podcast in + self.viewModels.push(ViewModel(podcast: podcast)) + }) + onSuccess() }, + onFailure: { _ in }) + } +} diff --git a/SEDaily-IOS/Bookmark/StateBookmarkView.swift b/SEDaily-IOS/Bookmark/StateBookmarkView.swift new file mode 100644 index 0000000..accf225 --- /dev/null +++ b/SEDaily-IOS/Bookmark/StateBookmarkView.swift @@ -0,0 +1,70 @@ +// +// StateBookmarkView.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/16/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit + +protocol StateViewDelegate: class { + func refreshPressed() +} + +/// Generic view for states used in view controllers +class StateView: UIView { + private var stackView: UIStackView! + private var refreshButton: UIButton! + weak var delegate: StateViewDelegate? + + init( + frame: CGRect, + text: String, + showLoadingIndicator: Bool, + showRefreshButton: Bool, + delegate: StateViewDelegate?) { + super.init(frame: frame) + + self.delegate = delegate + + self.stackView = UIStackView() + self.stackView.axis = .vertical + self.addSubview(self.stackView) + + let label = UILabel(text: text) + + let horizontalStackView = UIStackView() + horizontalStackView.spacing = 10 + if showLoadingIndicator { + let activityIndicator = UIActivityIndicatorView() + activityIndicator.startAnimating() + activityIndicator.color = UIColor.gray + horizontalStackView.addArrangedSubview(activityIndicator) + } + horizontalStackView.addArrangedSubview(label) + + self.stackView.addArrangedSubview(horizontalStackView) + + if showRefreshButton { + self.refreshButton = UIButton() + self.refreshButton.setTitle(L10n.tapToRefresh, for: .normal) + self.refreshButton.setTitleColor(UIColor.init(hex: 0x007AFF), for: .normal) + self.refreshButton.addTarget(self, action: #selector(self.refreshPressed), for: .touchUpInside) + self.stackView.addArrangedSubview(self.refreshButton) + } + + self.stackView.snp.makeConstraints { (make) in + make.centerX.centerY.equalToSuperview() + } + } + + @objc func refreshPressed() { + self.delegate?.refreshPressed() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/SEDaily-IOS/BookmarkService.swift b/SEDaily-IOS/BookmarkService.swift new file mode 100644 index 0000000..36b5c10 --- /dev/null +++ b/SEDaily-IOS/BookmarkService.swift @@ -0,0 +1,61 @@ +// +// BookmarkService.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/24/19. +// Copyright © 2019 Altalogy All rights reserved. +// + +protocol BookmarkServiceUIDelegate: class { + func bookmarkUIDidChange(isBookmarked: Bool) + func bookmarkUIImmediateUpdate() +} + +import Foundation + +class BookmarkService { + + let networkService: API = API() + + var podcastViewModel: PodcastViewModel + + + weak var UIDelegate: BookmarkServiceUIDelegate? + + init(podcastViewModel: PodcastViewModel) { + self.podcastViewModel = podcastViewModel + } + + func setBookmark() { + guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) + return + } + UIDelegate?.bookmarkUIImmediateUpdate() + self.setBookmark(value: true) + } + + private func setBookmark(value: Bool) { + let podcastId = podcastViewModel._id + networkService.setBookmarkPodcast( + value: value, + podcastId: podcastId, + completion: { [weak self] (success, active) in + guard success != nil else { + self?.UIDelegate?.bookmarkUIImmediateUpdate() + return } + if success == true { + guard let active = active else { return } + self?.updateBookmarked(active: active) + } else { self?.UIDelegate?.bookmarkUIImmediateUpdate() } + }) + Analytics2.bookmarkButtonPressed(podcastId: podcastViewModel._id) + } + + private func updateBookmarked(active: Bool) { + self.podcastViewModel.isBookmarked = active + let userInfo = ["viewModel": podcastViewModel] + NotificationCenter.default.post(name: .viewModelUpdated, object: nil, userInfo: userInfo) + self.UIDelegate?.bookmarkUIDidChange(isBookmarked: active) + } +} diff --git a/SEDaily-IOS/Comment.swift b/SEDaily-IOS/Comment.swift new file mode 100644 index 0000000..c8b1800 --- /dev/null +++ b/SEDaily-IOS/Comment.swift @@ -0,0 +1,43 @@ +// +// Comment.swift +// SEDaily-IOS +// +// Created by jason on 2/1/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation +import Down +import SwiftMoment + +public struct Comment: Codable { + let author: Author + let _id: String + let content: String + let dateCreated: String + let deleted: Bool + let rootEntity: String + let replies: [Comment]? + let score: Int + let upvoted: Bool? + let downvoted: Bool? + let parentComment: String? +} + +extension Comment { + + func getDatedCreatedPretty() -> String { + return moment(self.dateCreated)?.fromNow() ?? "" + } + + func commentBody() -> NSAttributedString { + // This should be done on the server + let down = Down(markdownString: self.content) + if let content = try? down.toAttributedString() { + return content + } else { + return NSAttributedString(string: self.content) + } + + } +} diff --git a/SEDaily-IOS/CommentCell.swift b/SEDaily-IOS/CommentCell.swift new file mode 100644 index 0000000..37b390d --- /dev/null +++ b/SEDaily-IOS/CommentCell.swift @@ -0,0 +1,182 @@ +// +// CommentCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/6/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + + +import UIKit +import Reusable + +protocol CommentReplyTableViewCellDelegate: class { + func replyToCommentPressed(comment: Comment) +} +class CommentCell: UITableViewCell, Reusable { + + var avatarImage: UIImageView! + var authorLabel: UILabel! + var contentLabel: UILabel! + var dateLabel: UILabel! + var verticalLine: UIView! + var replyButton: UIButton! + var callback: ((Comment)->Void) = {_ in } + // Update for reply cell + var isReplyCell: Bool = false { + didSet { + replyButton.isHidden = isReplyCell + avatarImage.snp.updateConstraints { (make) -> Void in + let leftPadding: CGFloat = isReplyCell ? 55.0 : 15.0 + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: leftPadding)) + } + } + } + weak var delegate: CommentReplyTableViewCellDelegate? + + var comment: Comment? { + didSet { + let prettyDate = comment?.getDatedCreatedPretty() + dateLabel.text = prettyDate + contentLabel.text = comment?.content + authorLabel.text = comment?.author.displayName() + + if let imageString = comment?.author.avatarUrl { + let url = URL(string: imageString) + avatarImage.kf.setImage(with: url) + } else { + avatarImage.image = UIImage(named: "profile-icon-9") + } + } + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + self.selectionStyle = .none + setupLayout() + setupTargets() + + } + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } + + @objc func replyTapped(sender: UIButton) { + if let comment = comment { + delegate?.replyToCommentPressed(comment: comment) + } + } + @objc func avatarTapped() { + if let comment = comment { + callback(comment) + } + } +} + +extension CommentCell { + private func setupLayout() { + func setupLabels() { + + verticalLine = UIView() + verticalLine.backgroundColor = .clear + contentView.addSubview(verticalLine) + + authorLabel = UILabel() + self.contentView.addSubview(authorLabel) + authorLabel.textColor = Stylesheet.Colors.dark + authorLabel.numberOfLines = 1 + authorLabel.font = UIFont(name: "OpenSans-Semibold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + + contentLabel = UILabel() + contentView.addSubview(contentLabel) + contentLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 12)) + contentLabel.numberOfLines = 0 + contentLabel.textColor = Stylesheet.Colors.dark + + dateLabel = UILabel() + contentView.addSubview(dateLabel) + dateLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 12)) + dateLabel.textColor = Stylesheet.Colors.grey + + replyButton = UIButton() + contentView.addSubview(replyButton) + replyButton.setTitleColor(Stylesheet.Colors.base, for: .normal) + replyButton.setTitle(L10n.replyButtonTitle, for: .normal) + replyButton.titleLabel?.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + } + + func setupAvatarImage() { + avatarImage = UIImageView() + contentView.addSubview(avatarImage) + avatarImage.contentMode = .scaleAspectFill + avatarImage.clipsToBounds = true + avatarImage.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 15) + avatarImage.kf.indicatorType = .activity + } + + func setupConstraints() { + avatarImage.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 5.0)) + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 30.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 30.0)) + } + authorLabel.snp.makeConstraints { (make) -> Void in + make.centerY.equalTo(avatarImage.snp_centerY) + make.left.equalTo(avatarImage.snp_right).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + make.rightMargin.equalTo(dateLabel.snp_left) + } + dateLabel.snp.makeConstraints { (make) -> Void in + make.centerY.equalTo(avatarImage.snp_centerY) + make.rightMargin.equalToSuperview() + } + contentLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(authorLabel) + make.top.equalTo(authorLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + make.rightMargin.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + + } + + replyButton.snp.makeConstraints { (make) -> Void in + make.top.equalTo(contentLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 5.0)) + make.left.equalTo(authorLabel) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 5.0)) + } + + verticalLine.snp.makeConstraints { (make) -> Void in + make.width.equalTo(2.0) + make.top.equalTo(avatarImage.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + make.centerX.equalTo(avatarImage.snp_centerX) + make.bottom.equalTo(contentLabel.snp_bottom) + } + } + + setupLabels() + setupAvatarImage() + setupConstraints() + } +} + + +extension CommentCell { + + private func setupTargets() { + + replyButton.addTarget(self, action: #selector(CommentCell.replyTapped), for: .touchUpInside) + + let tapGestureRecognizerAvatar = UITapGestureRecognizer(target: self, action: #selector(CommentCell.avatarTapped)) + let tapGestureRecognizerLabel = UITapGestureRecognizer(target: self, action: #selector(CommentCell.avatarTapped)) + + avatarImage.addGestureRecognizer(tapGestureRecognizerAvatar) + authorLabel.addGestureRecognizer(tapGestureRecognizerLabel) + + self.isUserInteractionEnabled = true + avatarImage.isUserInteractionEnabled = true + authorLabel.isUserInteractionEnabled = true + } +} diff --git a/SEDaily-IOS/CommentReplyTableViewCell.swift b/SEDaily-IOS/CommentReplyTableViewCell.swift new file mode 100644 index 0000000..4e168d0 --- /dev/null +++ b/SEDaily-IOS/CommentReplyTableViewCell.swift @@ -0,0 +1,63 @@ +// +// CommentReplyTableViewCell.swift +// SEDaily-IOS +// +// Created by jason on 2/2/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class CommentReplyTableViewCell: UITableViewCell { + + @IBOutlet weak var avatarImage: UIImageView! + @IBOutlet weak var contentLabel: UILabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + var comment: Comment? { + didSet { + contentLabel.attributedText = comment?.commentBody() + let prettyDate = comment?.getDatedCreatedPretty() + dateLabel.text = prettyDate + usernameLabel.text = comment?.author.displayName() + + if let imageString = comment?.author.avatarUrl { + let url = URL(string: imageString) + avatarImage.kf.setImage(with: url) + } else { + avatarImage.image = UIImage(named: "profile-icon-9") + } + + if comment?.deleted == true { + contentLabel.textColor = UIColor.lightGray + } else { + contentLabel.textColor = UIColor.black + } + } + } + + override func awakeFromNib() { + super.awakeFromNib() + setupLayout() + + // Initialization code + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + +} + +extension CommentReplyTableViewCell { + private func setupLayout() { + func setupAvatarImage() { + avatarImage.contentMode = .scaleAspectFill + avatarImage.cornerRadius = avatarImage.frame.height / 2.0 + avatarImage.clipsToBounds = true + } + setupAvatarImage() + } +} diff --git a/SEDaily-IOS/CommentTableViewCell.swift b/SEDaily-IOS/CommentTableViewCell.swift new file mode 100644 index 0000000..0d0d4cf --- /dev/null +++ b/SEDaily-IOS/CommentTableViewCell.swift @@ -0,0 +1,63 @@ +// +// CommentTableViewCell.swift +// SEDaily-IOS +// +// Created by jason on 2/2/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + + +class CommentTableViewCell: UITableViewCell { + @IBOutlet weak var avatarImage: UIImageView! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var contentLabel: UILabel! + @IBOutlet weak var replyButton: UIButton! + @IBOutlet weak var dateLabel: UILabel! + weak var delegate: CommentReplyTableViewCellDelegate? + var hideReplyCell = false { + didSet { + replyButton.isHidden = hideReplyCell + } + } + + var comment: Comment? { + didSet { + let prettyDate = comment?.getDatedCreatedPretty() + dateLabel.text = prettyDate + contentLabel.attributedText = comment?.commentBody() + usernameLabel.text = comment?.author.displayName() + + if let imageString = comment?.author.avatarUrl { + let url = URL(string: imageString) + avatarImage.kf.setImage(with: url) + } else { + avatarImage.image = UIImage(named: "profile-icon-9") + } + if comment?.deleted == true { + contentLabel.textColor = UIColor.lightGray + } else { + contentLabel.textColor = UIColor.black + } + } + } + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + @IBAction func replyButtonPressed(_ sender: UIButton) { + if let comment = comment { + delegate?.replyToCommentPressed(comment: comment) + } + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + +} diff --git a/SEDaily-IOS/CommentsResponse.swift b/SEDaily-IOS/CommentsResponse.swift new file mode 100644 index 0000000..7a8a479 --- /dev/null +++ b/SEDaily-IOS/CommentsResponse.swift @@ -0,0 +1,13 @@ +// +// CommentsResponse.swift +// SEDaily-IOS +// +// Created by jason on 2/1/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +public struct CommentsResponse: Codable { + let result: [Comment] +} diff --git a/SEDaily-IOS/CommentsViewController.swift b/SEDaily-IOS/CommentsViewController.swift new file mode 100644 index 0000000..bd9ef9b --- /dev/null +++ b/SEDaily-IOS/CommentsViewController.swift @@ -0,0 +1,363 @@ +// +// CommentsViewController.swift +// SEDaily-IOS +// +// Created by jason on 1/31/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import MBProgressHUD + + +class CommentsViewController: UIViewController { + + var tableView: UITableView = UITableView() + + var postButton: UIButton! + var commentTextView: UITextView! + var postCommentView: UIView! + var statusLabel: UILabel! + var cancelReplyButton: UIButton! + let activityIndicator: UIActivityIndicatorView = UIActivityIndicatorView() + let placeholderText = L10n.commentsPlaceholder + + private let refreshControl = UIRefreshControl() + var rootEntityId: String? + + let networkService = API() + var comments: [Comment] = [] + + // This is set when user clicks on reply + var parentCommentSelected: Comment? { + didSet { + guard let parentComment = parentCommentSelected else { + // Hide + cancelReplyButton.isHidden = true + statusLabel.text = "" + return + } + cancelReplyButton.isHidden = false + + // Show the reply area + if let replyTo = parentComment.author.username { + statusLabel.text = "Reply to: \(replyTo)" + } else { + statusLabel.text = "Reply to: \(parentComment.content)" + } + + } + } + + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + override func viewDidLoad() { + super.viewDidLoad() + setupLayout() + updateUIBasedOnUser() + loadComments() + setupPullToRefresh() + } + + func updateUIBasedOnUser () { + // Hide if user is not logged in OR if user is limited (no true username) + if !isFullUser() { + tableView.tableFooterView = UIView() + tableView.reloadData() + } else { + setupLayoutForUser() + tableView.reloadData() + } + } + + func setupPullToRefresh () { + // Setup pull down to refresh + if #available(iOS 10.0, *) { + tableView.refreshControl = refreshControl + } else { + tableView.addSubview(refreshControl) + } + refreshControl.addTarget(self, action: #selector(refreshListData(_:)), for: .valueChanged) + } + + @objc private func refreshListData(_ sender: Any) { + loadComments() + } + + + // Should be in the model but only used by comments for now: + func isFullUser() -> Bool { + if !UserManager.sharedInstance.isCurrentUserLoggedIn() { + return false + } + return true + } + + func loadComments() { + activityIndicator.startAnimating() + + guard let rootEntityId = rootEntityId else { + statusLabel.text = L10n.thereWasAProblem + return + } + networkService.getComments(rootEntityId: rootEntityId, onSuccess: { [weak self] (comments) in + + guard let reversedComments = self?.reverseCommentsArray(array: comments) else { + self?.statusLabel.text = L10n.thereWasAProblem + return + } + guard let flatComments = self?.flattenComments(nestedComments: reversedComments) else { + self?.statusLabel.text = L10n.thereWasAProblem + return + } + + self?.comments = flatComments + self?.tableView.reloadData() + self?.activityIndicator.stopAnimating() + self?.refreshControl.endRefreshing() + }, onFailure: { [weak self] (_) in + self?.activityIndicator.stopAnimating() + self?.statusLabel.text = L10n.thereWasAProblem // TODO: This status labels shows only when signed in because belong to footerView + }) + } + + // Edit for not showing deleted + func flattenComments(nestedComments: [Comment]) -> [Comment] { + var flatComments: [Comment] = [] + for nestedComment in nestedComments { + guard !nestedComment.deleted else { continue } + flatComments.append(nestedComment) + if let replies = nestedComment.replies { + for reply in replies { + guard !reply.deleted else { continue } + flatComments.append(reply) + } + } + } + return flatComments + } + + private func reverseCommentsArray(array: [Comment])-> [Comment] { + let reversed = Array(array.reversed()) + return reversed + } + + @objc func postCommentTapped() { + self.view.endEditing(true) + guard let rootEntityId = rootEntityId, + let commentContent = commentTextView.text, + commentContent != "", + commentContent != placeholderText else { + return + } + + networkService.createComment(rootEntityId: rootEntityId, parentComment: parentCommentSelected, commentContent: commentContent, onSuccess: { [weak self] in + + + self?.commentTextView.text = self?.placeholderText + self?.commentTextView.isUserInteractionEnabled = true + self?.postButton.isEnabled = true + + self?.statusLabel.text = L10n.succcessfullySubmitted + self?.parentCommentSelected = nil + self?.loadComments() + + }, onFailure: { [weak self] (_) in + self?.statusLabel.text = L10n.thereWasAProblem + self?.postButton.isEnabled = true + self?.commentTextView.isUserInteractionEnabled = true + }) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + @objc func cancelReplyTapped(_ sender: UIButton) { + parentCommentSelected = nil + } + +} + + +extension CommentsViewController: CommentReplyTableViewCellDelegate { + func replyToCommentPressed(comment: Comment) { + parentCommentSelected = comment + commentTextView.becomeFirstResponder() + } +} + +extension CommentsViewController: UITableViewDelegate, UITableViewDataSource { + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return comments.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + let comment = comments[indexPath.row] + + if comment.parentComment != nil { + let cell: CommentCell = tableView.dequeueReusableCell(for: indexPath) + cell.isReplyCell = true + cell.comment = comment + cell.callback = { [weak self] user in self?.loadUser(comment: comment) } + + return cell + } else { + let cell: CommentCell = tableView.dequeueReusableCell(for: indexPath) + cell.delegate = self + cell.replyButton.isHidden = !isFullUser() + cell.isReplyCell = false + cell.comment = comment + cell.callback = { [weak self] user in self?.loadUser(comment: comment) } + return cell + } + } + + public func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } +} + +extension CommentsViewController { + private func loadUser(comment: Comment) { + ProgressIndicator.showBlockingProgress() + + networkService.getUser(userId: comment.author._id!) { user in + guard let user = user else { + ProgressIndicator.hideBlockingProgress() + return } + + let vc: ProfileViewController = ProfileViewController() + vc.user = user + self.navigationController?.pushViewController(vc, animated: true) + ProgressIndicator.hideBlockingProgress() + } + } +} + +extension CommentsViewController { + private func setupLayout() { + func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(cellType: CommentCell.self) + view.addSubview(tableView) + + tableView.separatorColor = .clear + tableView.contentInset = UIEdgeInsets(top: 20,left: 0,bottom: 0,right: 0) + tableView.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.right.equalToSuperview() + make.left.equalToSuperview() + } + self.view.layoutIfNeeded() + } + + func setupActivityIndicator() { + activityIndicator.center = self.view.center + activityIndicator.hidesWhenStopped = true + activityIndicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyle.gray + view.addSubview(activityIndicator) + } + + title = L10n.comments + setupTableView() + setupActivityIndicator() + } +} + +extension CommentsViewController { + private func setupLayoutForUser() { + postButton = UIButton() + postCommentView = UIView(frame: CGRect(x: 0.0, y: 0.0, width: UIView.getValueScaledByScreenWidthFor(baseValue: 375.0), height: UIView.getValueScaledByScreenWidthFor(baseValue: 100.0))) + postCommentView.backgroundColor = .white + + commentTextView = UITextView(frame: .zero) + commentTextView.toolbarPlaceholder = L10n.typeHere + commentTextView.text = placeholderText + commentTextView.textColor = Stylesheet.Colors.grey + commentTextView.delegate = self + commentTextView.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + commentTextView.textAlignment = NSTextAlignment.natural + commentTextView.backgroundColor = .white + + postCommentView.addSubview(postButton) + postButton.setTitle(L10n.post, for: .normal) + postButton.titleLabel?.font = UIFont(name: "OpenSans-Semibold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + postButton.setTitleColor(Stylesheet.Colors.base, for: .normal) + + postCommentView.addSubview(commentTextView) + + tableView.tableFooterView = postCommentView + + statusLabel = UILabel() + statusLabel.text = "" + statusLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + statusLabel.textColor = Stylesheet.Colors.grey + + cancelReplyButton = UIButton() + postCommentView.addSubview(cancelReplyButton) + cancelReplyButton.setTitleColor(.red, for: .normal) + cancelReplyButton.titleLabel?.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + cancelReplyButton.setTitle(L10n.cancelReplyButtonTitle, for: .normal) + cancelReplyButton.titleLabel?.textColor = .red + cancelReplyButton.isHidden = true + + postCommentView.addSubview(statusLabel) + + commentTextView.snp.makeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.right.equalTo(postButton.snp_left).offset(UIView.getValueScaledByScreenWidthFor(baseValue: -10)) + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + } + postButton.snp.makeConstraints { (make) -> Void in + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.top.equalTo(commentTextView) + } + + statusLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(commentTextView) + make.bottom.equalTo(commentTextView.snp_top) + } + cancelReplyButton.snp.makeConstraints { (make) -> Void in + make.left.equalTo(statusLabel.snp_right).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.centerY.equalTo(statusLabel.snp_centerY) + } + + postButton.addTarget(self, action: #selector(CommentsViewController.postCommentTapped), for: .touchUpInside) + postButton.isEnabled = false + cancelReplyButton.addTarget(self, action: #selector(CommentsViewController.cancelReplyTapped), for: .touchUpInside) + } +} + +extension CommentsViewController: UITextViewDelegate { + + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == Stylesheet.Colors.grey { + postButton.isEnabled = true + textView.text = nil + textView.textColor = Stylesheet.Colors.dark + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.isEmpty { + postButton.isEnabled = false + textView.text = placeholderText + textView.textColor = Stylesheet.Colors.grey + } + } +} diff --git a/SEDaily-IOS/ContainerViewController.swift b/SEDaily-IOS/ContainerViewController.swift index 66ed257..1786a53 100644 --- a/SEDaily-IOS/ContainerViewController.swift +++ b/SEDaily-IOS/ContainerViewController.swift @@ -9,35 +9,33 @@ import UIKit class ContainerViewController: UIViewController { - - var containerView = UIView() - + private var containerView = UIView() private var navController = UINavigationController() - private var tabController = CustomTabViewController() - - // MARK: - View Life Cycle - + private var customTabViewController: CustomTabViewController? + private var audioOverlayViewController: AudioOverlayViewController? + override func viewDidLoad() { super.viewDidLoad() - self.setupView() - self.automaticallyAdjustsScrollViewInsets = false } - + func navButtonPressed() { self.dismiss(animated: true, completion: nil) } - + override func viewDidLayoutSubviews() { self.add(asChildViewController: navController) } - - // MARK: - View Methods - + private func setupView() { + self.customTabViewController = CustomTabViewController(audioOverlayDelegate: self) self.view.addSubview(containerView) - + self.audioOverlayViewController = AudioOverlayViewController(audioOverlayDelegate: self) + self.addChildViewController(self.audioOverlayViewController!) + self.audioOverlayViewController?.view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.audioOverlayViewController!.view) + containerView.snp.makeConstraints { (make) -> Void in make.top.equalToSuperview() make.right.equalToSuperview() @@ -45,62 +43,69 @@ class ContainerViewController: UIViewController { make.bottom.equalToSuperview() } containerView.backgroundColor = Stylesheet.Colors.white - - let navVC = UINavigationController(rootViewController: tabController) + + self.audioOverlayViewController?.view.snp.makeConstraints { (make) in + make.left.right.equalToSuperview() + make.bottom.equalToSuperview().offset( + UIView.getValueScaledByScreenHeightFor( + baseValue: AudioOverlayViewController.audioControlsViewHeight)) + make.top.equalToSuperview().offset(UIScreen.main.bounds.height) + } + + let navVC = UINavigationController(rootViewController: customTabViewController!) navVC.view.backgroundColor = .white self.navController = navVC } - + func setContainerViewInset() { self.containerView.snp.updateConstraints { (make) -> Void in - make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenHeightFor(baseValue: 110)) + make.bottom.equalToSuperview().inset( + UIView.getValueScaledByScreenHeightFor( + baseValue: AudioOverlayViewController.audioControlsViewHeight)) } - + self.view.layoutIfNeeded() } - + func removeContainerViewInset() { containerView.snp.updateConstraints { (make) -> Void in make.bottom.equalToSuperview() } - + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut, animations: { self.view.layoutIfNeeded() }) } - - // MARK: - Helper Methods - private func add(asChildViewController viewController: UIViewController) { // Add Child View Controller addChildViewController(viewController) - + // Add Child View as Subview containerView.addSubview(viewController.view) - + // Configure Child View let height = containerView.height let width = containerView.width viewController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - + // Notify Child View Controller viewController.didMove(toParentViewController: self) } - + private func remove(asChildViewController viewController: UIViewController) { // Notify Child View Controller viewController.willMove(toParentViewController: nil) - + // Remove Child View From Superview viewController.view.removeFromSuperview() - + // Notify Child View Controller viewController.removeFromParentViewController() } - + private func removeAllViewControllers() { for child in childViewControllers { child.willMove(toParentViewController: nil) @@ -108,9 +113,39 @@ class ContainerViewController: UIViewController { child.removeFromParentViewController() } } - + // Have to set preferredStatusBarStyle here on first view controller override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return .default + } +} + +extension ContainerViewController: AudioOverlayDelegate { + func animateOverlayIn() { + self.setContainerViewInset() + self.audioOverlayViewController?.animateIn() + } + + func animateOverlayOut() { + self.audioOverlayViewController?.animateOut() + self.removeContainerViewInset() + } + + func playAudio(podcastViewModel: PodcastViewModel) { + self.audioOverlayViewController?.playAudio(podcastViewModel: podcastViewModel) + } + + func pauseAudio() { + self.audioOverlayViewController?.pauseAudio() + } + + func stopAudio() { + self.audioOverlayViewController?.stopAudio() + } + + func setCurrentShowingDetailView(podcastViewModel: PodcastViewModel?) { + self.audioOverlayViewController?.setCurrentShowingDetailView( + podcastViewModel: podcastViewModel) } + } diff --git a/SEDaily-IOS/CurrentlyPlaying.swift b/SEDaily-IOS/CurrentlyPlaying.swift new file mode 100644 index 0000000..535ad2b --- /dev/null +++ b/SEDaily-IOS/CurrentlyPlaying.swift @@ -0,0 +1,20 @@ +// +// CurrentlyPlaying.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/6/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +// This is a workaround to keep global playing state +class CurrentlyPlaying { +// static let shared = CurrentlyPlaying() +// private var currentlyPlayingId: String = "" +// func setCurrentlyPlaying(id: String) { +// currentlyPlayingId = id +// } +// func getCurrentlyPlayingId()-> String { +// return currentlyPlayingId +// } +} diff --git a/SEDaily-IOS/CustomTabViewController.swift b/SEDaily-IOS/CustomTabViewController.swift index 2188690..81e608f 100644 --- a/SEDaily-IOS/CustomTabViewController.swift +++ b/SEDaily-IOS/CustomTabViewController.swift @@ -1,97 +1,239 @@ +//// +//// CustomTabViewController.swift +//// SEDaily-IOS +//// +//// Created by Craig Holliday on 6/26/17. +//// Copyright © 2017 Koala Tea. All rights reserved. +//// // -// CustomTabViewController.swift -// SEDaily-IOS -// -// Created by Craig Holliday on 6/26/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -// -// CustomTabViewController.swift -// Kibbl-IOS -// -// Created by Craig Holliday on 4/28/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -import UIKit -import SwifterSwift -import SnapKit -import SwiftIcons - -class CustomTabViewController: UITabBarController, UITabBarControllerDelegate { - - var ifset = false - - override func viewDidLoad() { - super.viewDidLoad() - - // Do any additional setup after loading the view. - - delegate = self - - self.view.backgroundColor = .white - - setupTabs() - setupTitleView() - } - - override func viewDidAppear(_ animated: Bool) { - setupNavBar() - } - - func setupNavBar() { - let rightBarButton = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(self.leftBarButtonPressed)) - self.navigationItem.rightBarButtonItem = rightBarButton - - switch UserManager.sharedInstance.getActiveUser().isLoggedIn() { - case false: - let leftBarButton = UIBarButtonItem(title: L10n.loginTitle, style: .done, target: self, action: #selector(self.loginButtonPressed)) - self.navigationItem.leftBarButtonItem = leftBarButton - case true: - let leftBarButton = UIBarButtonItem(title: L10n.logoutTitle, style: .done, target: self, action: #selector(self.logoutButtonPressed)) - self.navigationItem.leftBarButtonItem = leftBarButton - } - } - - @objc func leftBarButtonPressed() { - let vc = SearchTableViewController() - self.navigationController?.pushViewController(vc) - } - - @objc func loginButtonPressed() { - let vc = LoginViewController() - self.navigationController?.pushViewController(vc) - } - - @objc func logoutButtonPressed() { - UserManager.sharedInstance.logoutUser() - self.setupNavBar() - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } - - func setupTabs() { - let layout = UICollectionViewLayout() - - self.viewControllers = [ - PodcastPageViewController(), - GeneralCollectionViewController(collectionViewLayout: layout, type: .recommended), - GeneralCollectionViewController(collectionViewLayout: layout, type: .top), - ] - - self.tabBar.backgroundColor = .white - self.tabBar.isTranslucent = false - } - - func setupTitleView() { - let height = UIView.getValueScaledByScreenHeightFor(baseValue: 40) - let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: height, height: height)) - imageView.contentMode = .scaleAspectFit - imageView.image = #imageLiteral(resourceName: "Logo_BarButton") - self.navigationItem.titleView = imageView - } -} +// +//import UIKit +//import MessageUI +//import PopupDialog +//import SnapKit +//import StoreKit +//import SwifterSwift +//import SwiftIcons +//import Firebase +// +//class CustomTabViewController: UITabBarController, UITabBarControllerDelegate { +// +// var ifset = false +// +// var actionSheet = UIAlertController() +// +// +// init() { +// super.init(nibName: nil, bundle: nil) +// } +// +// required init?(coder aDecoder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// override func viewDidLoad() { +// super.viewDidLoad() +// delegate = self +// self.view.backgroundColor = .white +// +// setupTabs() +// setupTitleView() +// self.tabBar.tintColor = Stylesheet.Colors.base +// } +// +// override func viewDidAppear(_ animated: Bool) { +// super.viewDidAppear(animated) +// setupNavBar() +// +// AskForReview.tryToExecute { didExecute in +// if didExecute { +// self.askForReview() +// } +// } +// } +// +// func setupNavBar() { +// let rightBarButton = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(self.rightBarButtonPressed)) +// self.navigationItem.rightBarButtonItem = rightBarButton +// +// switch UserManager.sharedInstance.getActiveUser().isLoggedIn() { +// case false: +// let leftBarButton = UIBarButtonItem(title: L10n.loginTitle, style: .done, target: self, action: #selector(self.loginButtonPressed)) +// self.navigationItem.leftBarButtonItem = leftBarButton +// case true: +// let leftBarButton = UIBarButtonItem(title: L10n.logoutTitle, style: .plain, target: self, action: #selector(self.leftBarButtonPressed)) +// +// // Hacky way to show bars icon +// let iconSize: CGFloat = 16.0 +// let image = UIImage(bgIcon: .fontAwesome(.bars), bgTextColor: .clear, bgBackgroundColor: .clear, topIcon: .fontAwesome(.bars), topTextColor: .white, bgLarge: false, size: CGSize(width: iconSize, height: iconSize)) +// leftBarButton.image = image +// leftBarButton.imageInsets = UIEdgeInsets(top: 0, left: -(iconSize / 2), bottom: 0, right: 0) +// +// self.navigationItem.leftBarButtonItem = leftBarButton +// } +// } +// +// @objc func rightBarButtonPressed() { +// let layout = UICollectionViewLayout() +// //var searchCollectionViewController = SearchCollectionViewController(collectionViewLayout: layout, audioOverlayDelegate: self.audioOverlayDelegate) +// //self.navigationController?.pushViewController(searchCollectionViewController) +// Analytics2.searchNavButtonPressed() +// } +// +// @objc func leftBarButtonPressed() { +// self.setupLogoutSubscriptionActionSheet() +// self.actionSheet.show() +// } +// +// @objc func loginButtonPressed() { +// Analytics2.loginNavButtonPressed() +// let vc = LoginViewController() +// self.navigationController?.pushViewController(vc) +// } +// +// @objc func logoutButtonPressed() { +// Analytics2.logoutNavButtonPressed() +// UserManager.sharedInstance.logoutUser() +// self.setupNavBar() +// } +// +// override func didReceiveMemoryWarning() { +// super.didReceiveMemoryWarning() +// // Dispose of any resources that can be recreated. +// } +// +// func setupTabs() { +// let layout = UICollectionViewLayout() +// +// +// //FeedViewController.audioOverlayDelegate = self.audioOverlayDelegate +// +// +// +// +// +// +//// let latestVC = PodcastPageViewController(audioOverlayDelegate: self.audioOverlayDelegate) +//// let bookmarksVC = BookmarkCollectionViewController(collectionViewLayout: layout, audioOverlayDelegate: self.audioOverlayDelegate) +//// let downloadsVC = DownloadsCollectionViewController(collectionViewLayout: layout, audioOverlayDelegate: self.audioOverlayDelegate) +// let profileVC = ProfileViewController() +// +// +// +// +// +// +// self.viewControllers = [ +//// latestVC, +//// bookmarksVC, +//// downloadsVC, +// profileVC +// ] +// +//// #if DEBUG +//// // This will cause the tab bar to overflow so it will be auto turned into "More ..." +//// let debugStoryboard = UIStoryboard.init(name: "Debug", bundle: nil) +//// let debugViewController = debugStoryboard.instantiateViewController( +//// withIdentifier: "DebugTabViewController") +//// if let viewControllers = self.viewControllers { +//// self.viewControllers = viewControllers + [debugViewController] +//// } +//// #endif +// +// self.tabBar.backgroundColor = .white +// self.tabBar.isTranslucent = false +// } +// +// func setupTitleView() { +// let height = UIView.getValueScaledByScreenHeightFor(baseValue: 40) +// let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: height, height: height)) +// imageView.contentMode = .scaleAspectFit +// imageView.image = #imageLiteral(resourceName: "Logo_BarButton") +// self.navigationItem.titleView = imageView +// +// } +// +// private func askForReview() { +// let popup = PopupDialog(title: L10n.enthusiasticHello, +// message: L10n.appReviewPromptQuestion, +// gestureDismissal: false) +// let feedbackPopup = PopupDialog(title: L10n.appReviewApology, +// message: L10n.appReviewGiveFeedbackQuestion) +// let feedbackYesButton = DefaultButton(title: L10n.enthusiasticSureSendEmail) { +// if MFMailComposeViewController.canSendMail() { +// let mail = MFMailComposeViewController() +// mail.mailComposeDelegate = self +// mail.setToRecipients(["jeff@softwareengineeringdaily.com"]) +// mail.setSubject(L10n.appReviewEmailSubject) +// +// self.present(mail, animated: true, completion: nil) +// } else { +// let emailUnsupportedPopup = PopupDialog(title: L10n.emailUnsupportedOnDevice, message: L10n.emailUnsupportedMessage) +// let okayButton = DefaultButton(title: L10n.genericOkay) { +// emailUnsupportedPopup.dismiss() +// } +// emailUnsupportedPopup.addButton(okayButton) +// self.present(emailUnsupportedPopup, animated: true, completion: nil) +// } +// } +// +// let feedbackNoButton = DefaultButton(title: L10n.noWithGratitude) { +// popup.dismiss() +// } +// +// let yesButton = DefaultButton(title: L10n.enthusiasticYes) { +// SKStoreReviewController.requestReview() +// AskForReview.setReviewed() +// } +// +// let noButton = DefaultButton(title: L10n.genericNo) { +// popup.dismiss() +// self.present(feedbackPopup, animated: true, completion: nil) +// } +// +// popup.addButtons([yesButton, noButton]) +// feedbackPopup.addButtons([feedbackYesButton, feedbackNoButton]) +// self.present(popup, animated: true, completion: nil) +// } +// +// func setupLogoutSubscriptionActionSheet() { +// self.actionSheet = UIAlertController(title: "", message: "Whatcha wanna do?", preferredStyle: .actionSheet) +// self.actionSheet.popoverPresentationController?.barButtonItem = self.navigationItem.leftBarButtonItem +// +// switch UserManager.sharedInstance.getActiveUser().hasPremium { +// case true: +// self.actionSheet.addAction(title: "View Subscription", style: .default, isEnabled: true) { _ in +// // Show view subscription status view +// let rootVC = SubscriptionStatusViewController() +// let navVC = UINavigationController(rootViewController: rootVC) +// self.present(navVC, animated: true, completion: nil) +// } +// case false: +//// self.actionSheet.addAction(title: "Purchase Subscription", style: .default, isEnabled: true) { _ in +//// // Show purchase subscription view +//// let rootVC = PurchaseSubscriptionViewController() +//// let navVC = UINavigationController(rootViewController: rootVC) +//// self.present(navVC, animated: true, completion: nil) +//// } +// break +// default: break +// } +// +// self.actionSheet.addAction(title: "Logout", style: .destructive, isEnabled: true) { _ in +// self.logoutButtonPressed() +// } +// self.actionSheet.addAction(title: "Cancel", style: .cancel, isEnabled: true) { _ in +// self.actionSheet.dismiss(animated: true, completion: nil) +// } +// } +//} +// +//extension MainTabBarController: MFMailComposeViewControllerDelegate { +// func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { +// switch result { +// case .sent: +// AskForReview.setReviewed() +// default: break +// } +// } +//} diff --git a/SEDaily-IOS/Debouncer.swift b/SEDaily-IOS/Debouncer.swift new file mode 100644 index 0000000..f0d28f8 --- /dev/null +++ b/SEDaily-IOS/Debouncer.swift @@ -0,0 +1,30 @@ +// +// Debouncer.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/24/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation + +class Debouncer: NSObject { + var callback: (() -> ()) + var delay: Double + weak var timer: Timer? + + init(delay: Double, callback: @escaping (() -> ())) { + self.delay = delay + self.callback = callback + } + + func call() { + timer?.invalidate() + let nextTimer = Timer.scheduledTimer(timeInterval: delay, target: self, selector: #selector(Debouncer.fireNow), userInfo: nil, repeats: false) + timer = nextTimer + } + + @objc func fireNow() { + self.callback() + } +} diff --git a/SEDaily-IOS/Debug/Debug.storyboard b/SEDaily-IOS/Debug/Debug.storyboard new file mode 100644 index 0000000..9f03f35 --- /dev/null +++ b/SEDaily-IOS/Debug/Debug.storyboard @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SEDaily-IOS/Debug/DebugTabViewController.swift b/SEDaily-IOS/Debug/DebugTabViewController.swift new file mode 100644 index 0000000..8f92a36 --- /dev/null +++ b/SEDaily-IOS/Debug/DebugTabViewController.swift @@ -0,0 +1,76 @@ +// +// DebugTabViewController.swift +// SEDaily-IOS +// +// Created by Justin Lam on 10/6/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import UIKit + +protocol TestHookTableViewCell { + func configure(testHook: TestHook) +} + +class DebugTabViewController: UIViewController { + @IBOutlet private weak var tableView: UITableView! + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + self.tabBarItem = UITabBarItem(title: "Debug", image: nil, selectedImage: nil) + //self.tabBarItem.setIcon(icon: .fontAwesome(.bug)) + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.tableView.dataSource = self + self.tableView.rowHeight = UITableViewAutomaticDimension + self.tableView.estimatedRowHeight = 44 + + let useStagingEndpoint = TestHookBool( + id: .useStagingEndpoint, + name: "Use Staging Endpoint") + useStagingEndpoint.defaultValue = false + TestHookManager.add(testHook: useStagingEndpoint) + + let clearAlreadyLoadedToday = TestHookEvent( + id: .clearAlreadyLoadedToday, + name: "Clear Already Loaded Today") + clearAlreadyLoadedToday.execute = { + PodcastRepository.clearLoadedToday() + } + TestHookManager.add(testHook: clearAlreadyLoadedToday) + + let viewDiskTestHook = TestHookEvent( + id: .viewDisk, + name: "View Disk") + viewDiskTestHook.execute = { + PodcastDataSource.getAll(diskKey: .PodcastFolder, completion: { (podcast) in + print(podcast ?? "") + }) + } + TestHookManager.add(testHook: viewDiskTestHook) + } +} + +extension DebugTabViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return TestHookManager.testHooksArray.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let testHook = TestHookManager.testHooksArray[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: testHook.reuseId, for: indexPath) + cell.selectionStyle = .none + if let testHookTableViewCell = cell as? TestHookTableViewCell { + testHookTableViewCell.configure(testHook: testHook) + return cell + } + fatalError("No supported table view cell") + } +} diff --git a/SEDaily-IOS/Debug/TestHook.swift b/SEDaily-IOS/Debug/TestHook.swift new file mode 100644 index 0000000..b741c04 --- /dev/null +++ b/SEDaily-IOS/Debug/TestHook.swift @@ -0,0 +1,24 @@ +// +// TestHook.swift +// SEDaily-IOS +// +// Created by Justin Lam on 10/7/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +class TestHook { + let name: String + let id: TestHookId + + var reuseId: String { + return "" + } + var order = 0 + + init(id: TestHookId, name: String) { + self.id = id + self.name = name + } +} diff --git a/SEDaily-IOS/Debug/TestHookBool.swift b/SEDaily-IOS/Debug/TestHookBool.swift new file mode 100644 index 0000000..3336908 --- /dev/null +++ b/SEDaily-IOS/Debug/TestHookBool.swift @@ -0,0 +1,39 @@ +// +// TestHookBool.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/15/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +class TestHookBool: TestHook { + override var reuseId: String { + return "TestHookBoolCellId" + } + + private var _defaultValue = false + + var value: Bool { + get { + return UserDefaults.standard.bool(forKey: self.id.rawValue) + } + + set(newValue) { + UserDefaults.standard.set(newValue, forKey: self.id.rawValue) + } + } + + var defaultValue: Bool { + get { + return self._defaultValue + } + set(newDefaultValue) { + self._defaultValue = newDefaultValue + if UserDefaults.standard.object(forKey: self.id.rawValue) == nil { + UserDefaults.standard.set(newDefaultValue, forKey: self.id.rawValue) + } + } + } +} diff --git a/SEDaily-IOS/Debug/TestHookBoolTableViewCell.swift b/SEDaily-IOS/Debug/TestHookBoolTableViewCell.swift new file mode 100644 index 0000000..ad969c6 --- /dev/null +++ b/SEDaily-IOS/Debug/TestHookBoolTableViewCell.swift @@ -0,0 +1,30 @@ +// +// TestHookBoolTableViewCell.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/15/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit + +class TestHookBoolTableViewCell: UITableViewCell, TestHookTableViewCell { + @IBOutlet private weak var title: UILabel! + @IBOutlet private weak var boolSwitch: UISwitch! + + private weak var testHookBool: TestHookBool? + + @IBAction func valueChanged(_ sender: Any) { + self.testHookBool?.value = self.boolSwitch.isOn + } + + func configure(testHook: TestHook) { + self.title.text = testHook.name + + if let testHookBool = testHook as? TestHookBool { + self.testHookBool = testHookBool + self.boolSwitch.isOn = testHookBool.value + } + } +} diff --git a/SEDaily-IOS/Debug/TestHookEvent.swift b/SEDaily-IOS/Debug/TestHookEvent.swift new file mode 100644 index 0000000..fe4bb7c --- /dev/null +++ b/SEDaily-IOS/Debug/TestHookEvent.swift @@ -0,0 +1,16 @@ +// +// TestHookEvent.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/15/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +class TestHookEvent: TestHook { + override var reuseId: String { + return "TestHookEventCellId" + } + var execute: (() -> Void)? +} diff --git a/SEDaily-IOS/Debug/TestHookEventTableViewCell.swift b/SEDaily-IOS/Debug/TestHookEventTableViewCell.swift new file mode 100644 index 0000000..c37b44e --- /dev/null +++ b/SEDaily-IOS/Debug/TestHookEventTableViewCell.swift @@ -0,0 +1,27 @@ +// +// TestHookEventTableViewCell.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/15/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit + +class TestHookEventTableViewCell: UITableViewCell, TestHookTableViewCell { + @IBOutlet private weak var title: UILabel! + private var testHookEvent: TestHookEvent? + + func configure(testHook: TestHook) { + self.title.text = testHook.name + + if let testHookEvent = testHook as? TestHookEvent { + self.testHookEvent = testHookEvent + } + } + + @IBAction func buttonTapped(_ sender: Any) { + self.testHookEvent?.execute?() + } +} diff --git a/SEDaily-IOS/Debug/TestHookManager.swift b/SEDaily-IOS/Debug/TestHookManager.swift new file mode 100644 index 0000000..8d5385f --- /dev/null +++ b/SEDaily-IOS/Debug/TestHookManager.swift @@ -0,0 +1,42 @@ +// +// TestHookManager.swift +// SEDaily-IOS +// +// Created by Justin Lam on 12/15/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +enum TestHookId: String { + case useStagingEndpoint + case viewDisk + case getPodcastBookmarks + case clearAlreadyLoadedToday +} + +class TestHookManager { + private(set) static var testHooksMap = [TestHookId: TestHook]() + private(set) static var testHooksArray = [TestHook]() + private static var order = 0 + + private init() { + } + + static func add(testHook: TestHook) { + testHook.order = order + order += 1 + TestHookManager.testHooksMap[testHook.id] = testHook + + TestHookManager.testHooksArray = Array(TestHookManager.testHooksMap.values) + TestHookManager.testHooksArray.sort { $0.order < $1.order } + } + + static func testHookBool(id: TestHookId) -> TestHookBool? { + return TestHookManager.testHooksMap[id] as? TestHookBool + } + + static func testHookEvent(id: TestHookId) -> TestHookEvent? { + return TestHookManager.testHooksMap[id] as? TestHookEvent + } +} diff --git a/SEDaily-IOS/DescriptionCell.swift b/SEDaily-IOS/DescriptionCell.swift new file mode 100644 index 0000000..ff1e5e5 --- /dev/null +++ b/SEDaily-IOS/DescriptionCell.swift @@ -0,0 +1,76 @@ +// +// WebViewCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/2/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable +import WebKit + +class DescriptionCell: UITableViewCell, Reusable { + + var descriptionLabel: UILabel! + //webView.navigationDelegate = self + + var viewModel: PodcastViewModel = PodcastViewModel() { + willSet { + guard newValue != self.viewModel else { return } + } + didSet { + //updateUI() + var str: String! + // Due to asynchronuous nature of decoding html content, this is a better way to do it + DispatchQueue.global(qos: .background).async { [weak self] in + str = self?.viewModel.podcastDescription + DispatchQueue.main.async { + print(str) + self?.descriptionLabel.text = str + self?.layoutIfNeeded() + } + } + } + } + + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + + } + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + +} + +extension DescriptionCell { + private func setupLayout() { + descriptionLabel = UILabel() + descriptionLabel.numberOfLines = 0 + descriptionLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + descriptionLabel.textColor = Stylesheet.Colors.dark + self.contentView.addSubview(descriptionLabel) + descriptionLabel.snp.makeConstraints { (make) in + make.left.right.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + } + } +} + + + diff --git a/SEDaily-IOS/DiskKeys.swift b/SEDaily-IOS/DiskKeys.swift new file mode 100644 index 0000000..3cfbbf4 --- /dev/null +++ b/SEDaily-IOS/DiskKeys.swift @@ -0,0 +1,18 @@ +// +// DiskKeys.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 11/16/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation + +enum DiskKeys: String { + case PodcastFolder = "Podcasts" + case OfflineDownloads = "Offline-Downloads" + + var folderPath: String { + return self.rawValue + "/" + self.rawValue + ".json" + } +} diff --git a/SEDaily-IOS/DownloadService.swift b/SEDaily-IOS/DownloadService.swift new file mode 100644 index 0000000..b33ae7f --- /dev/null +++ b/SEDaily-IOS/DownloadService.swift @@ -0,0 +1,65 @@ +// +// DownloadService.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/24/19. +// Copyright © 2019 Altalogy All rights reserved. +// + +protocol DownloadServiceUIDelegate: class { + func downloadUIDidChange(progress: Int?, success: Bool?) +} +import Foundation +import UIKit + +class DownloadService { + + weak var UIDelegate: DownloadServiceUIDelegate? + + var podcastViewModel: PodcastViewModel + + private let downloadManager = OfflineDownloadsManager.sharedInstance + + init(podcastViewModel: PodcastViewModel) { + self.podcastViewModel = podcastViewModel + } + + func savePodcast() { + + let podcastId = self.podcastViewModel._id + + self.downloadManager.save( + podcast: self.podcastViewModel, + onProgress: { progress in + // Show progress + let progressAsInt = Int((progress * 100).rounded()) + self.UIDelegate?.downloadUIDidChange(progress: progressAsInt, success: nil) + self.podcastViewModel.downloadingProgress = progressAsInt + }, + onSuccess: { [weak self] in + // for search bug fix + guard let strongSelf = self else { return } + let userInfo = ["viewModel": strongSelf.podcastViewModel] + NotificationCenter.default.post(name: .viewModelUpdated, object: nil, userInfo: userInfo) + // + strongSelf.UIDelegate?.downloadUIDidChange(progress: nil, success: true) + }, + onFailure: { error in + self.UIDelegate?.downloadUIDidChange(progress: nil, success: false) + guard let error = error else { return } + // Alert Error + Helpers.alertWithMessage(title: error.localizedDescription.capitalized, message: "") + + }) + } + + func deletePodcast() { + + Helpers.alertWithMessageCustomAction(title: L10n.deletePodcast, message: nil, actionTitle: L10n.deletePodcastButtonTitle) { [weak self] in + guard let strongSelf = self else { return } + strongSelf.downloadManager.deletePodcast(podcast: strongSelf.podcastViewModel) { + strongSelf.UIDelegate?.downloadUIDidChange(progress: nil, success: false) + } + } + } +} diff --git a/SEDaily-IOS/DownloadsCollectionViewController.swift b/SEDaily-IOS/DownloadsCollectionViewController.swift new file mode 100644 index 0000000..6e1ec97 --- /dev/null +++ b/SEDaily-IOS/DownloadsCollectionViewController.swift @@ -0,0 +1,214 @@ +// +// DownloadsCollectionViewController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/21/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation + +import UIKit +import StatefulViewController + + +/// Collection view controller for viewing all downloads for the user. +class DownloadsCollectionViewController: UICollectionViewController, StatefulViewController, MainCoordinated { + + var mainCoordinator: MainFlowCoordinator? + + + private let reuseIdentifier = "Cell" + + private var viewModelController = DownloadsViewModelController() + + private var progressController = PlayProgressModelController() + + lazy var skeletonCollectionView: SkeletonCollectionView = { + return SkeletonCollectionView(frame: self.collectionView!.frame) + }() + + + override init(collectionViewLayout layout: UICollectionViewLayout) { + super.init(collectionViewLayout: layout) + self.tabBarItem = UITabBarItem(title: L10n.tabBarDownloads, image: UIImage(named: "download_panel_outline"), selectedImage: UIImage(named: "download_panel")) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + + self.collectionView?.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + self.errorView = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + self.errorView?.backgroundColor = .green + + let refreshControl = UIRefreshControl() + refreshControl.addTarget( + self, + action: #selector(pullToRefresh(_:)), + for: .valueChanged) + self.collectionView?.refreshControl = refreshControl + } + + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + @objc private func pullToRefresh(_ sender: Any) { + self.refreshView(useCache: true) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.setupInitialViewState() + progressController.retrieve() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.refreshView(useCache: true) + } + + private func refreshView(useCache: Bool) { + self.startLoading() + + self.updateLoadingView(view: skeletonCollectionView) + self.updateEmptyView(view: + StateView( + frame: CGRect.zero, + text: L10n.noDownloads, + showLoadingIndicator: false, + showRefreshButton: false, + delegate: self)) + + if useCache { + self.viewModelController.retrieveCachedDownloadsData(onSuccess: { + self.endLoading() + DispatchQueue.main.async { + self.collectionView?.reloadData() + self.collectionView?.refreshControl?.endRefreshing() + } + }) + } + } + + private func updateLoadingView(view: UIView) { + self.loadingView?.removeFromSuperview() + self.loadingView = view + } + + private func updateEmptyView(view: UIView) { + self.emptyView?.removeFromSuperview() + self.emptyView = view + } + + func hasContent() -> Bool { + return self.viewModelController.viewModelsCount > 0 + } + + override func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + override func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int) -> Int { + return self.viewModelController.viewModelsCount + } + + override func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + + if let viewModel = self.viewModelController.viewModel(at: indexPath.row) { + cell.viewModel = viewModel + + let upvoteService = UpvoteService(podcastViewModel: viewModel) + let bookmarkService = BookmarkService(podcastViewModel: viewModel) + + cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) + + cell.viewModel = viewModel + cell.upvoteService = upvoteService + cell.bookmarkService = bookmarkService + + cell.commentShowCallback = { [weak self] in + self?.commentsButtonPressed(viewModel) + + } + } + + return cell + } + + override func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath) { + if let viewModel = viewModelController.viewModel(at: indexPath.row) { + + let vc = EpisodeViewController() + vc.viewModel = viewModel + mainCoordinator?.configure(viewController: vc) + self.navigationController?.pushViewController(vc, animated: true) + + } + } +} + +extension DownloadsCollectionViewController: StateViewDelegate { + func refreshPressed() { + self.refreshView(useCache: true) + + } +} + +extension DownloadsCollectionViewController { + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + viewModelDidChange(viewModel: viewModel) + } + } + } +} + +extension DownloadsCollectionViewController { + private func viewModelDidChange(viewModel: PodcastViewModel) { + self.viewModelController.update(with: viewModel) + } +} + + +extension DownloadsCollectionViewController { + func commentsButtonPressed(_ viewModel: PodcastViewModel) { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } +} diff --git a/SEDaily-IOS/DownloadsViewModelController.swift b/SEDaily-IOS/DownloadsViewModelController.swift new file mode 100644 index 0000000..dccff86 --- /dev/null +++ b/SEDaily-IOS/DownloadsViewModelController.swift @@ -0,0 +1,52 @@ +// +// DownloadsViewModelController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/21/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + + +import Foundation + +public class DownloadsViewModelController { + typealias Model = Podcast + typealias ViewModel = PodcastViewModel + typealias SuccessCallback = () -> Void + typealias ErrorCallback = (RepositoryError?) -> Void + + private let repository = PodcastRepository() + private var viewModels: [ViewModel?] = [] + + var viewModelsCount: Int { + return viewModels.count + } + + func viewModel(at index: Int) -> ViewModel? { + guard index >= 0 && index < viewModelsCount else { return nil } + return viewModels[index] + } + + func update(with podcast: PodcastViewModel) { + let index = self.viewModels.index { (item) -> Bool in + return item?._id == podcast._id + } + guard let modelsIndex = index else { return } + self.viewModels.remove(at: modelsIndex) + self.viewModels.insert(podcast, at: modelsIndex) + + // Tell repository to update Datasource + self.repository.updateDataSource(diskKey: .PodcastFolder, item: podcast.baseModelRepresentation) + } + + func retrieveCachedDownloadsData(onSuccess: @escaping SuccessCallback) { + self.repository.retrieveDownloadsData( + onSuccess: { (podcasts) in + self.viewModels.removeAll() + podcasts.forEach({ podcast in + self.viewModels.push(ViewModel(podcast: podcast)) + }) + onSuccess() }, + onFailure: { _ in }) + } +} diff --git a/SEDaily-IOS/EpisodeHeaderCell.swift b/SEDaily-IOS/EpisodeHeaderCell.swift new file mode 100644 index 0000000..02f6878 --- /dev/null +++ b/SEDaily-IOS/EpisodeHeaderCell.swift @@ -0,0 +1,337 @@ +// +// EpisodeHeaderCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/30/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import UIKit +import Reusable + +class EpisodeHeaderCell: UITableViewCell, Reusable { + + var titleLabel: UILabel! + var guestThumb: UIImageView! + var miscDetailsLabel: UILabel! + + var separator: UIView! + + var playButton: UIButton! + var downloadButton: UIButton! + var relatedLinksButton: UIButton! + + var actionView: ActionView! + + var upvoteService: UpvoteService? + var bookmarkService: BookmarkService? + var downloadService: DownloadService? { didSet { downloadService?.UIDelegate = self }} + + var downloadButtonCallBack: (()-> Void) = {} + var relatedLinksButtonCallBack: (()-> Void) = {} + var playButtonCallBack: ((_ isPlaying: Bool)-> Void) = {_ in } + + + + var viewModel: PodcastViewModel = PodcastViewModel() { + willSet { + guard newValue != self.viewModel else { return } + } + didSet { + updateUI() + } + } + + var isPlaying: Bool = false { didSet { + playButton.isSelected = isPlaying + }} + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + setupButtonsTargets() + } + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } + + private func setupButtonsTargets() { + actionView.upvoteButton.addTarget(self, action: #selector(EpisodeHeaderCell.upvoteTapped), for: .touchUpInside) + actionView.bookmarkButton.addTarget(self, action: #selector(EpisodeHeaderCell.bookmarkTapped), for: .touchUpInside) + actionView.commentButton.addTarget(self, action: #selector(EpisodeHeaderCell.commentTapped), for: .touchUpInside) + playButton.addTarget(self, action: #selector(EpisodeHeaderCell.playTapped), for: .touchUpInside) + relatedLinksButton.addTarget(self, action: #selector(EpisodeHeaderCell.relatedLinksTapped), for: .touchUpInside) + downloadButton.addTarget(self, action: #selector(EpisodeHeaderCell.downloadTapped), for: .touchUpInside) + } + + @objc func upvoteTapped() { + + Haptics.feedback(.impact) + upvoteService?.UIDelegate = self + upvoteService?.upvote() + } + + @objc func bookmarkTapped() { + + Haptics.feedback(.impact) + bookmarkService?.UIDelegate = self + bookmarkService?.setBookmark() + } + @objc func commentTapped() { + + Haptics.feedback(.impact) + actionView.commentShowCallback() + } + @objc func downloadTapped() { + + Haptics.feedback(.impact) + switch viewModel.isDownloaded { + case true: + downloadService?.deletePodcast() + default: + downloadService?.savePodcast() + } + } + + @objc func playTapped() { + Haptics.feedback(.notification) + playButtonCallBack(isPlaying) + + } + + @objc func relatedLinksTapped() { + Haptics.feedback(.impact) + relatedLinksButtonCallBack() + } +} + +extension EpisodeHeaderCell { + private func setupLayout() { + func setupLabels() { + titleLabel = UILabel() + self.contentView.addSubview(titleLabel) + titleLabel.textColor = Stylesheet.Colors.dark + titleLabel.numberOfLines = 3 + titleLabel.font = UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 24)) + + miscDetailsLabel = UILabel() + contentView.addSubview(miscDetailsLabel) + miscDetailsLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 11)) + miscDetailsLabel.textColor = Stylesheet.Colors.dark + } + func setupGuestThumb() { + guestThumb = UIImageView() + contentView.addSubview(guestThumb) + guestThumb.contentMode = .scaleAspectFill + guestThumb.clipsToBounds = true + guestThumb.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 25) + guestThumb.kf.indicatorType = .activity + } + func setupPlayButton() { + playButton = UIButton() + contentView.addSubview(playButton) + + playButton.borderWidth = 1.0 + playButton.borderColor = Stylesheet.Colors.base + + playButton.setTitle("Play", for: .normal) + playButton.setTitleColor(UIColor.white, for: .normal) + playButton.setBackgroundColor(color: Stylesheet.Colors.base, forState: .normal) + playButton.setImage(UIImage(named: "Triangle"), for: .normal) + + + playButton.setTitle("Stop", for: .selected) + playButton.setTitleColor(Stylesheet.Colors.base, for: .selected) + playButton.setBackgroundColor(color: .white, forState: .selected) + playButton.setImage(UIImage(named: "Square"), for: .selected) + + playButton.imageEdgeInsets = UIEdgeInsetsMake(0.0, -10.0, 0.0, 0.0) + playButton.titleEdgeInsets = UIEdgeInsetsMake(0.0, 0.0, 0.0, -10.0) + playButton.backgroundColor = Stylesheet.Colors.base + playButton.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 25.0) + } + func setupDownloadButton() { + downloadButton = UIButton() + contentView.addSubview(downloadButton) + downloadButton.setImage(UIImage(named: "download_outline"), for: .normal) + downloadButton.setImage(UIImage(named: "download"), for: .selected) + downloadButton.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 25.0) + downloadButton.backgroundColor = Stylesheet.Colors.light + + } + func setupRelatedLinksButton() { + relatedLinksButton = UIButton() + contentView.addSubview(relatedLinksButton) + relatedLinksButton.setImage(UIImage(named: "link"), for: .normal) + } + func setupActionView() { + actionView = ActionView() + actionView.setupComponents(superview: contentView) + } + func setupSeparator() { + separator = UIView() + contentView.addSubview(separator) + separator.backgroundColor = Stylesheet.Colors.light + } + + func setupContraints() { + titleLabel.snp.makeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + } + miscDetailsLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(guestThumb.snp_right).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + make.rightMargin.equalTo(playButton.snp_left).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10.0)) + make.centerY.equalTo(guestThumb.snp_centerY) + } + guestThumb.snp.makeConstraints { (make) -> Void in + make.top.equalTo(titleLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 20.0)) + make.left.equalTo(titleLabel) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)) + } + playButton.snp.makeConstraints { (make) -> Void in + make.top.equalTo(titleLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 20.0)) + make.right.equalTo(downloadButton.snp_left).inset(UIView.getValueScaledByScreenWidthFor(baseValue: -10.0)) + + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 120.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)) + } + downloadButton.snp.makeConstraints { (make) -> Void in + make.top.equalTo(titleLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 20.0)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 50.0)) + make.right.equalToSuperview().inset((UIView.getValueScaledByScreenWidthFor(baseValue: 15.0))) + } + relatedLinksButton.snp.makeConstraints { (make) -> Void in + make.centerY.equalTo(actionView.actionStackView) + make.centerX.equalTo(downloadButton) + } + actionView.setupContraints() + actionView.actionStackView.snp.makeConstraints { (make) -> Void in + make.top.equalTo(playButton.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.left.equalTo(titleLabel) + make.bottom.equalTo(separator.snp_top) + } + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(5.0) + } + } + + setupLabels() + setupGuestThumb() + setupPlayButton() + setupDownloadButton() + setupRelatedLinksButton() + setupActionView() + setupSeparator() + setupContraints() + } +} + +extension EpisodeHeaderCell { + private func updateUI() { + + viewModel.getLastUpdatedAsDateWith { [weak self] (date) in + guard let strongSelf = self else { return } + setupMiscDetailsLabel(timeLength: nil, date: date, isDownloaded: strongSelf.viewModel.isDownloaded) + } + + self.titleLabel.text = viewModel.podcastTitle + func setupMiscDetailsLabel(timeLength: Int?, date: Date?, isDownloaded: Bool) { + let dateString = date?.dateString() ?? "" + miscDetailsLabel.text = dateString + } + + func setupGuestThumb(imageURL: URL?) { + guestThumb.kf.cancelDownloadTask() + guard let imageURL = imageURL else { + guestThumb.image = #imageLiteral(resourceName: "SEDaily_Logo") + return + } + guestThumb.kf.setImage(with: imageURL, placeholder: UIImage(named: "Logo_BarButton"), options: [.transition(.fade(0.2))]) + } + func updateUpvote() { + actionView.upvoteCountLabel.text = String(viewModel.score) + actionView.upvoteButton.isSelected = viewModel.isUpvoted + actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark + actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: 13) : UIFont(name: "OpenSans", size: 13) + } + + func updateBookmark() { + actionView.bookmarkButton.isSelected = viewModel.isBookmarked + } + + func updateDownloadButton() { + downloadButton.isSelected = viewModel.isDownloaded + } + + updateUpvote() + updateBookmark() + updateDownloadButton() + setupGuestThumb(imageURL: viewModel.guestImageURL) + } +} + +extension EpisodeHeaderCell: UpvoteServiceUIDelegate { + func upvoteUIDidChange(isUpvoted: Bool, score: Int) { + actionView.upvoteButton.isSelected = isUpvoted + actionView.upvoteCountLabel.text = String(score) + updateLabelStyle() + } + + func upvoteUIImmediateUpdate() { + guard let tempScore = Int(actionView.upvoteCountLabel.text ?? "0") else { return } + actionView.upvoteCountLabel.text = actionView.upvoteButton.isSelected ? String(tempScore - 1) : String(tempScore + 1) + actionView.upvoteButton.isSelected = !actionView.upvoteButton.isSelected + updateLabelStyle() + } +} + +extension EpisodeHeaderCell { + func updateLabelStyle() { + actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark + actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: 13) : UIFont(name: "OpenSans", size: 13) + } +} + +extension EpisodeHeaderCell: BookmarkServiceUIDelegate { + func bookmarkUIDidChange(isBookmarked: Bool) { + actionView.bookmarkButton.isSelected = isBookmarked + } + func bookmarkUIImmediateUpdate() { + actionView.bookmarkButton.isSelected = !actionView.bookmarkButton.isSelected + } +} + +extension EpisodeHeaderCell: DownloadServiceUIDelegate { + func downloadUIDidChange(progress: Int?, success: Bool?) { + + guard let progress = progress else { + guard let success = success else { return } + downloadButton.isUserInteractionEnabled = true + downloadButton.isSelected = success + downloadButton.setTitle("", for: .normal) + downloadButton.setImage(UIImage(named: "download_outline"), for: .normal) + downloadButton.setImage(UIImage(named: "download"), for: .selected) + downloadButton.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 25.0) + downloadButton.backgroundColor = Stylesheet.Colors.light + return + } + downloadButton.isUserInteractionEnabled = false + let progressString: String = String(progress) + "%" + downloadButton.setImage(nil, for: .normal) + downloadButton.setTitleColor(Stylesheet.Colors.dark, for: .normal) + downloadButton.titleLabel?.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 12)) + downloadButton.setTitle(progressString, for: .normal) + } +} + + diff --git a/SEDaily-IOS/EpisodeViewController.swift b/SEDaily-IOS/EpisodeViewController.swift new file mode 100644 index 0000000..3e49022 --- /dev/null +++ b/SEDaily-IOS/EpisodeViewController.swift @@ -0,0 +1,352 @@ +// +// EpisodeViewController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/30/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +protocol WebViewCellDelegate { + func updateWebViewHeight(didCalculateHeight height: CGFloat) +} + +protocol EpisodeViewDelegate: class { + func playAudio(podcastViewModel: PodcastViewModel) + func stopAudio() +} + +import UIKit +import WebKit +import Tags + +class EpisodeViewController: UIViewController, AudioControllable, MainCoordinated, Stateful { + + var stateController: StateController? + + var mainCoordinator: MainFlowCoordinator? + + + let tagsView = TagsView() + let tagsScrollView = UIScrollView(frame: CGRect(x: 0.0, y: 0.0, width: 375.0, height: 50.0)) + + + //weak var delegate: PodcastDetailViewControllerDelegate? + weak var audioControlDelegate: EpisodeViewDelegate? + + var loaded: Bool = false // to check if HTML content has loaded + var webView: WKWebView = WKWebView() + let networkService: API = API() + + var topics:[Topic] = [] { didSet { + tagsView.set(contentsOf: topicsStringArray) + } + } + + var topicsStringArray: [String] { + get { + return topics.map{ $0.name } + } + } + + var webViewHeight: CGFloat = 600 + + + + var viewModel: PodcastViewModel = PodcastViewModel() { + willSet { + guard newValue != self.viewModel else { return } + } + didSet { + tableView.reloadData() + } + } + var isPlaying = false { didSet { + tableView.reloadData() + + }} + var transcriptURL: String? + var tableView = UITableView() + + + required override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.addSubview(tableView) + + + + self.navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Share", style: .plain, target: self, action: #selector(EpisodeViewController.shareTapped)) + + tableView.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.right.equalToSuperview() + make.left.equalToSuperview() + } + + + tableView.register(cellType: EpisodeHeaderCell.self) + tableView.register(cellType: WebViewCell.self) + tableView.register(cellType: TagsCell.self) + tableView.rowHeight = UITableViewAutomaticDimension + tableView.estimatedRowHeight = 50.0 + tableView.separatorStyle = .none + + tableView.delegate = self + tableView.dataSource = self + tagsView.delegate = self + + + + + tagsScrollView.addSubview(tagsView) + tableView.tableHeaderView = tagsScrollView + setupTagsHeaderLayout() + tableView.backgroundColor = .white + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveReloadRequest(_:)), + name: .reloadEpisodeView, + object: nil) + + getTrascriptURL() + getTopics() + } + + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.isPlaying = stateController?.getCurrentlyPlayingId() == viewModel._id + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } + + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + func playButtonPressed(isPlaying: Bool) { + if !isPlaying { + self.audioControlDelegate?.playAudio(podcastViewModel: viewModel) + AskForReview.triggerEvent() + self.isPlaying = true + } else { + self.audioControlDelegate?.stopAudio() + self.isPlaying = false + } + } + + @objc func shareTapped() { + + if let link = viewModel.postLinkURL { + let objectsToShare = [link] + let activityVC = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil) + self.present(activityVC, animated: true, completion: nil) + } + } +} + + +extension EpisodeViewController { + private func relatedLinksButtonPressed() { + Analytics2.relatedLinksButtonPressed(podcastId: viewModel._id) + let relatedLinksStoryboard = UIStoryboard.init(name: "RelatedLinks", bundle: nil) + guard let relatedLinksViewController = relatedLinksStoryboard.instantiateViewController( + withIdentifier: "RelatedLinksViewController") as? RelatedLinksViewController else { + return + } + let podcastId = viewModel._id + relatedLinksViewController.postId = podcastId + relatedLinksViewController.transcriptURL = transcriptURL + self.navigationController?.pushViewController(relatedLinksViewController, animated: true) + } + func commentsButtonPressed() { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } +} + +extension EpisodeViewController { + private func getTrascriptURL() { + networkService.getPost(podcastId: viewModel._id, completion: { [weak self] (success, result) in + if success { + guard let transcriptURL = result?.transcriptURL else { return } + self?.transcriptURL = transcriptURL + } + }) + } +} + + +extension EpisodeViewController { + private func getTopics() { + networkService.getTopicsForPost(podcastId: viewModel._id, onSuccess: { [weak self] data in + self?.topics = data + }, onFailure: { _ in print("error")}) + } +} + +extension EpisodeViewController { + private func setupTagsHeaderLayout() { + + tagsScrollView.showsHorizontalScrollIndicator = false + + tagsView.lastTagTitleColor = .white + tagsView.lastTagLayerColor = Stylesheet.Colors.base + tagsView.lastTagBackgroundColor = Stylesheet.Colors.base + + tagsView.tagLayerRadius = 5 + tagsView.tagLayerWidth = 1 + tagsView.tagLayerColor = Stylesheet.Colors.base + tagsView.tagTitleColor = Stylesheet.Colors.base + tagsView.tagBackgroundColor = .white + tagsView.tagFont = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 14)) ?? .systemFont(ofSize: 14) + tagsView.lineBreakMode = .byTruncatingMiddle + + tagsScrollView.snp.makeConstraints{ (make) in + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 5)) + make.bottom.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 5)) + make.height.equalTo(50) + make.width.equalTo(UIScreen.main.bounds.width) + } + + tagsView.translatesAutoresizingMaskIntoConstraints = false + tagsView.snp.makeConstraints { (make) in + make.left.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.right.bottom.top.equalToSuperview() + make.height.equalTo(50) + make.width.equalTo(9000) //arbitrary value wider than the actual screen + } + } +} + + +extension EpisodeViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + switch indexPath.row { + case 0: + let cell: EpisodeHeaderCell = tableView.dequeueReusableCell(for: indexPath) + cell.selectionStyle = .none + cell.bookmarkService = BookmarkService(podcastViewModel: viewModel) + cell.upvoteService = UpvoteService(podcastViewModel: viewModel) + cell.downloadService = DownloadService(podcastViewModel: viewModel) + + cell.isPlaying = isPlaying + cell.viewModel = viewModel + cell.playButtonCallBack = { [weak self] isPlaying in + self?.playButtonPressed(isPlaying: isPlaying) + } + cell.relatedLinksButtonCallBack = { [weak self] in + self?.relatedLinksButtonPressed() + } + cell.actionView.commentShowCallback = { [weak self] in + self?.commentsButtonPressed() + } + return cell + + default: + let cell: WebViewCell = tableView.dequeueReusableCell(for: indexPath) + var htmlString = HtmlHelper.getHTML(html: viewModel.encodedPodcastDescription) + cell.webViewHeight = webViewHeight + cell.webView.loadHTMLString(htmlString, baseURL: nil) + cell.webView.navigationDelegate = cell + cell.delegate = self + return cell + } + } + + + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 2 + } + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + +} + +extension EpisodeViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableViewAutomaticDimension + } +} + + +extension EpisodeViewController: WebViewCellDelegate { + func updateWebViewHeight(didCalculateHeight height: CGFloat) { + if !loaded { + webViewHeight = height + tableView.reloadData() + loaded = true + } else { return } + } +} + +extension EpisodeViewController { + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + guard viewModel._id == self.viewModel._id else { return } + self.viewModel = viewModel + } + } + } +} + +extension EpisodeViewController { + @objc func onDidReceiveReloadRequest(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + guard viewModel._id == self.viewModel._id else { return } + self.isPlaying = false + } + } + } +} + + + +extension EpisodeViewController: TagsDelegate { + + // Tag Touch Action + func tagsTouchAction(_ tagsView: TagsView, tagButton: TagButton) { + let layout = UICollectionViewLayout() + let topic = topics[tagButton.index] + var postsForTopicCollectionViewController = PostsForTopicCollectionViewController(collectionViewLayout: layout, topic: topic) + mainCoordinator?.configure(viewController: postsForTopicCollectionViewController) + self.navigationController?.pushViewController(postsForTopicCollectionViewController, animated: true) + } + // Last Tag Touch Action + func tagsLastTagAction(_ tagsView: TagsView, tagButton: TagButton) { + + } + // TagsView Change Height + func tagsChangeHeight(_ tagsView: TagsView, height: CGFloat) { + } +} + + diff --git a/SEDaily-IOS/FeedItem.swift b/SEDaily-IOS/FeedItem.swift new file mode 100644 index 0000000..f1ea496 --- /dev/null +++ b/SEDaily-IOS/FeedItem.swift @@ -0,0 +1,20 @@ +// +// FeedItem.swift +// SEDaily-IOS +// +// Created by jason on 5/15/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + + + +import Foundation + +public struct FeedItem: Codable { + let _id: String + let randomOrder: Double + var relatedLink: RelatedLink + // let author: Author // Is a string at times.. +} diff --git a/SEDaily-IOS/FeedItemCell.swift b/SEDaily-IOS/FeedItemCell.swift new file mode 100644 index 0000000..4753ed7 --- /dev/null +++ b/SEDaily-IOS/FeedItemCell.swift @@ -0,0 +1,162 @@ +//// +//// ThreadCell.swift +//// SEDaily-IOS +//// +//// Created by jason on 4/27/18. +//// Copyright © 2018 Koala Tea. All rights reserved. +//// +// +//import UIKit +// +//class FeedItemCell: UITableViewCell { +// +// @IBOutlet weak var itemTypeIcon: UIImageView! +// @IBOutlet weak var titleLabel: UILabel! +// @IBOutlet weak var imageHero: UIImageView! +// +// @IBOutlet weak var upVoteButton: UIButton! +// @IBOutlet weak var scoreLabel: UILabel! +// +// let networkService = API() +// +// override func awakeFromNib() { +// super.awakeFromNib() +// // Initialization code +// +// let iconSize = UIView.getValueScaledByScreenHeightFor(baseValue: 20) +// +// upVoteButton.setIcon(icon: .fontAwesome(.thumbsOUp), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .normal) +// upVoteButton.setIcon(icon: .fontAwesome(.thumbsUp), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .selected) +// +// upVoteButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .selected) +// itemTypeIcon.alpha = 0.3 +// } +// +// var thread: ForumThread? { +// didSet { +// if let thread = thread { +// _feedItem = thread +// relatedLinkFeedItem = nil +// +// titleLabel.text = thread.getPrettyTitle() +// scoreLabel.text = "\(thread.score)" +// if let upvoted = thread.upvoted { +// upVoteButton.isSelected = upvoted +// } else { +// upVoteButton.isSelected = false +// } +// +// imageHero.image = #imageLiteral(resourceName: "SEDaily_Logo") +// +// if thread.podcastEpisode != nil { +// itemTypeIcon.image = #imageLiteral(resourceName: "podcast") +// if let featuredImage = thread.podcastEpisode?.featuredImage { +// if let imgUrl = URL(string: featuredImage ) { +// imageHero.kf.setImage(with: imgUrl) +// } +// } +// } else { +// itemTypeIcon.image = #imageLiteral(resourceName: "bubbles") +// } +// layoutSubviews() +// } +// } +// } +// +// var relatedLinkFeedItem: FeedItem? { +// didSet { +// imageHero.image = #imageLiteral(resourceName: "SEDaily_Logo") +// itemTypeIcon.image = #imageLiteral(resourceName: "relatedlink") +// if let relatedLinkFeedItem = relatedLinkFeedItem { +// _feedItem = relatedLinkFeedItem.relatedLink +// thread = nil +// +// titleLabel.text = relatedLinkFeedItem.relatedLink.title +// +// scoreLabel.text = "\(relatedLinkFeedItem.relatedLink.score)" +// if let upvoted = relatedLinkFeedItem.relatedLink.upvoted { +// upVoteButton.isSelected = upvoted +// } else { +// upVoteButton.isSelected = false +// } +// +// if let image = relatedLinkFeedItem.relatedLink.image { +// if let imgUrl = URL(string: image ) { +// imageHero.kf.setImage(with: imgUrl) +// } +// } +// layoutSubviews() +// } +// } +// } +// +// var _feedItem: BaseFeedItem? +// +// @IBAction func upvotePressed(_ sender: UIButton) { +// guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { +// Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) +// return +// } +// +// // Immediately set UI to upvote +// +// self.setUpvoteTo(!self.upVoteButton.isSelected) +// if let feedItem = _feedItem { +// let entityId = feedItem._id +// if thread != nil { +// +// networkService.upvoteForum(entityId: entityId, completion: { (success, active) in +// guard success != nil else { return } +// if success == true { +// guard let active = active else { return } +// self.thread?.score = self.addScore(active: active) +// self.thread?.upvoted = active +// } +// }) +// } else if relatedLinkFeedItem != nil { +// networkService.upvoteRelatedLink(entityId: entityId, completion: { (success, active) in +// guard success != nil else { return } +// if success == true { +// guard let active = active else { return } +// self.relatedLinkFeedItem?.relatedLink.score = self.addScore(active: active) +// self.relatedLinkFeedItem?.relatedLink.upvoted = active +// +// } +// }) +// } +// } +// } +// +// func setUpvoteTo(_ bool: Bool) { +// _feedItem?.upvoted = bool +// self.upVoteButton.isSelected = bool +// } +// +// func addScore(active: Bool) -> Int { +// self.setUpvoteTo(active) +// if var _feedItem = _feedItem { +// guard active != false else { +// return self.setScoreTo(_feedItem.score - 1) +// } +// return self.setScoreTo(_feedItem.score + 1) +// } +// return 0 +// } +// +// func setScoreTo(_ score: Int) -> Int { +// if var _feedItem = _feedItem { +// guard _feedItem.score != score else { return 0} +// _feedItem.score = score +// self.scoreLabel.text = String(score) +// return score +// } +// return 0 +// } +// +// override func setSelected(_ selected: Bool, animated: Bool) { +// super.setSelected(selected, animated: animated) +// +// // Configure the view for the selected state +// } +// +//} diff --git a/SEDaily-IOS/FeedList.storyboard b/SEDaily-IOS/FeedList.storyboard new file mode 100644 index 0000000..1989c3f --- /dev/null +++ b/SEDaily-IOS/FeedList.storyboard @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SEDaily-IOS/FeedListViewController.swift b/SEDaily-IOS/FeedListViewController.swift new file mode 100644 index 0000000..5a6ebbf --- /dev/null +++ b/SEDaily-IOS/FeedListViewController.swift @@ -0,0 +1,227 @@ +// +// FeedListViewController.swift +// SEDaily-IOS +// +// Created by jason on 4/25/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class FeedListViewController: UIViewController { + + let networkService = API() + var threads: [Any] = [] + var lastThread:ForumThread? + //weak var audioOverlayDelegate: AudioOverlayDelegate? + + private let refreshControl = UIRefreshControl() + + + @IBOutlet weak var tableView: UITableView! + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + tableView.delegate = self + tableView.dataSource = self + + // Setup pull down to refresh + if #available(iOS 10.0, *) { + tableView.refreshControl = refreshControl + } else { + tableView.addSubview(refreshControl) + } + refreshControl.addTarget(self, action: #selector(refreshForumData(_:)), for: .valueChanged) + + tableView.estimatedRowHeight = 200 + tableView.rowHeight = UITableViewAutomaticDimension + } + + func displaySpinner(onView: UIView) -> UIView { + let spinnerView = UIView.init(frame: onView.bounds) + spinnerView.backgroundColor = UIColor.init(red: 0.5, green: 0.5, blue: 0.5, alpha: 0.5) + let ai = UIActivityIndicatorView.init(activityIndicatorStyle: .whiteLarge) + ai.startAnimating() + ai.center = spinnerView.center + + DispatchQueue.main.async { + spinnerView.addSubview(ai) + onView.addSubview(spinnerView) + } + + return spinnerView + } + + func removeSpinner(spinner: UIView) { + + DispatchQueue.main.async { + spinner.removeFromSuperview() + } + } + + override func viewDidAppear(_ animated: Bool) { + loadThreads() + Analytics2.feedViewed() + Tracker.logFeedViewed() + } + @objc private func refreshForumData(_ sender: Any) { + // Fetch Weather Data + loadThreads(refreshing: true) + } + + func loadThreads(refreshing: Bool = false) { + + var lastActivityBefore = "" + + var spinner: UIView? + if threads.count > 0 && refreshing == false { + // TODO: find last thread: + + if let thread = self.lastThread { + lastActivityBefore = thread.dateLastAcitiy + } + } else { + // First go: + if refreshing == false { + spinner = self.displaySpinner(onView: self.view) + } + } + + networkService.getFeed(lastActivityBefore: lastActivityBefore, onSuccess: { [weak self] (threads, lastForumThread) in + if refreshing { + self?.threads = [] + } + if let spinner = spinner { + self?.removeSpinner(spinner: spinner) + } + + self?.lastThread = lastForumThread + self?.threads += threads + self?.tableView.reloadData() + if refreshing { + self?.refreshControl.endRefreshing() + } + }, onFailure: { (_ ) in + print("error") + }) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + } + + */ + required init(coder aDecoder: NSCoder) { + super.init(coder: aDecoder)! + self.tabBarItem = UITabBarItem(title: L10n.tabBarFeed, image: #imageLiteral(resourceName: "activity_feed"), selectedImage: #imageLiteral(resourceName: "activity_feed_selected")) + } +} + +extension FeedListViewController: UITableViewDelegate, UITableViewDataSource { + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.threads.count + } + + // Row display. Implementers should *always* try to reuse cells by setting each cell's reuseIdentifier and querying for available reusable cells with dequeueReusableCellWithIdentifier: + // Cell gets various attributes set automatically based on table (separators) and data source (accessory views, editing controls) + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if let thread = self.threads[indexPath.row] as? ForumThread { + let cell = tableView.dequeueReusableCell(withIdentifier: "threadCell", for: indexPath) as? FeedItemCell + cell?.thread = thread + return cell! + } else { + + let cell = tableView.dequeueReusableCell(withIdentifier: "threadCell", for: indexPath) as? FeedItemCell + if let feedItem = self.threads[indexPath.row] as? FeedItem { + cell?.relatedLinkFeedItem = feedItem + } + +// let cell = tableView.dequeueReusableCell(withIdentifier: "relatedLinkCell", for: indexPath) as? RelatedLinkTableViewCell +// if let feedItem = self.threads[indexPath.row] as? FeedItem { +// cell?.relatedLink = feedItem.relatedLink +// } + return cell! + } + + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if indexPath.row == self.threads.count-1 { //you might decide to load sooner than -1 I guess... + loadThreads() + } + } + + func presentThreadComments(_ thread: ForumThread) { + let commentsStoryboard = UIStoryboard.init(name: "Comments", bundle: nil) + guard let commentsViewController = commentsStoryboard.instantiateViewController( + withIdentifier: "CommentsViewController") as? CommentsViewController else { + return + } + //commentsViewController.rootEntityId = thread._id + //commentsViewController.thread = thread + self.navigationController?.pushViewController(commentsViewController, animated: true) + + } + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + +// if let thread = self.threads[indexPath.row] as? ForumThread { +// if let liteEpisodeModel = thread.podcastEpisode { +// let spinner = self.displaySpinner(onView: self.view) +// networkService.getPost(podcastId: liteEpisodeModel._id) { (succeeded, fullPodcast) in +// self.removeSpinner(spinner: spinner) +// +// if succeeded && fullPodcast != nil { +// //if let audioOverlayDelegate = self.audioOverlayDelegate { +//// let vc = PodcastDetailViewController(nibName: nil, bundle: nil, audioOverlayDelegate: audioOverlayDelegate) +//// // TODO: check for safety: +//// vc.model = PodcastViewModel(podcast: fullPodcast!) +//// // vc.delegate = self +//// // vc.audioOverlayDelegate = self.audioOverlayDelegate +//// self.navigationController?.pushViewController(vc, animated: true) +//// } +//// } else { +//// self.presentThreadComments(thread) +//// } +// +// } +// } else { +// presentThreadComments(thread) +// } +// } else if let feedItem = self.threads[indexPath.row] as? FeedItem { +// // TODO: move to model +// var urlString = feedItem.relatedLink.url +// let urlPrefix = urlString.prefix(4) +// if urlPrefix != "http" { +// // Defaulting to http: +// if urlPrefix.prefix(3) == "://" { +// urlString = "http\(urlString)" +// } else { +// urlString = "http://\(urlString)" +// } +// } +// +// // Open the link: +// if let linkUrl = URL(string: urlString) { +// let vc = RelatedLinkWebVC() +// vc.url = linkUrl +// self.navigationController?.pushViewController(vc, animated: true) +// +// } else { +// print("link null") +// } + + } + +} diff --git a/SEDaily-IOS/FilterObject.swift b/SEDaily-IOS/FilterObject.swift index 19d049c..dea990f 100644 --- a/SEDaily-IOS/FilterObject.swift +++ b/SEDaily-IOS/FilterObject.swift @@ -12,20 +12,16 @@ struct FilterObject: Codable { let type: String let tags: [Int] var tagsAsString: String { - get { - let stringArray = tags.map { String($0) } - return stringArray.joined(separator: " ") - } + let stringArray = tags.map { String($0) } + return stringArray.joined(separator: " ") } let lastDate: String let categories: [Int] var categoriesAsString: String { - get { - let stringArray = categories.map { String($0) } - return stringArray.joined(separator: " ") - } + let stringArray = categories.map { String($0) } + return stringArray.joined(separator: " ") } - + init(type: String = "", tags: [Int] = [], lastDate: String = "", diff --git a/SEDaily-IOS/ForumHeaderViewController.swift b/SEDaily-IOS/ForumHeaderViewController.swift new file mode 100644 index 0000000..1b1dfd0 --- /dev/null +++ b/SEDaily-IOS/ForumHeaderViewController.swift @@ -0,0 +1,35 @@ +// +// ForumHeaderViewController.swift +// SEDaily-IOS +// +// Created by jason on 4/26/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class ForumHeaderViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/SEDaily-IOS/ForumList.storyboard b/SEDaily-IOS/ForumList.storyboard new file mode 100644 index 0000000..b508f97 --- /dev/null +++ b/SEDaily-IOS/ForumList.storyboard @@ -0,0 +1,169 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SEDaily-IOS/ForumListViewController.swift b/SEDaily-IOS/ForumListViewController.swift new file mode 100644 index 0000000..dca58ee --- /dev/null +++ b/SEDaily-IOS/ForumListViewController.swift @@ -0,0 +1,118 @@ +// +// ForumListViewController.swift +// SEDaily-IOS +// +// Created by jason on 4/25/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class ForumListViewController: UIViewController { + + let networkService = API() + var threads: [ForumThread] = [] + private let refreshControl = UIRefreshControl() + + @IBOutlet weak var tableView: UITableView! + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + tableView.delegate = self + tableView.dataSource = self + + // Setup pull down to refresh + if #available(iOS 10.0, *) { + tableView.refreshControl = refreshControl + } else { + tableView.addSubview(refreshControl) + } + refreshControl.addTarget(self, action: #selector(refreshForumData(_:)), for: .valueChanged) + + } + + override func viewDidAppear(_ animated: Bool) { + loadThreads() + } + @objc private func refreshForumData(_ sender: Any) { + // Fetch Weather Data + loadThreads(refreshing: true) + } + + func loadThreads(refreshing: Bool = false) { + + var lastActivityBefore = "" + if threads.count > 0 && refreshing == false { + let lastThread = threads[threads.count - 1] + lastActivityBefore = lastThread.dateLastAcitiy + } + + networkService.getForumThreads(lastActivityBefore: lastActivityBefore, onSuccess: { [weak self] (threads) in + if refreshing { + self?.threads = [] + } + self?.threads += threads + self?.tableView.reloadData() + if refreshing { + self?.refreshControl.endRefreshing() + } + }, onFailure: { (_ ) in + print("error") + }) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + } + + */ + required init(coder aDecoder: NSCoder) { + super.init(coder: aDecoder)! + self.tabBarItem = UITabBarItem(title: L10n.tabBarForum, image: #imageLiteral(resourceName: "bubbles"), selectedImage: #imageLiteral(resourceName: "bubbles_selected")) + } +} + +extension ForumListViewController: UITableViewDelegate, UITableViewDataSource { + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return self.threads.count + } + + // Row display. Implementers should *always* try to reuse cells by setting each cell's reuseIdentifier and querying for available reusable cells with dequeueReusableCellWithIdentifier: + // Cell gets various attributes set automatically based on table (separators) and data source (accessory views, editing controls) + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "forumThreadCell", for: indexPath) as? ForumThreadCell + + cell?.thread = self.threads[indexPath.row] + return cell! + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if indexPath.row == self.threads.count-1 { //you might decide to load sooner than -1 I guess... + loadThreads() + } + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let commentsStoryboard = UIStoryboard.init(name: "Comments", bundle: nil) + guard let commentsViewController = commentsStoryboard.instantiateViewController( + withIdentifier: "CommentsViewController") as? CommentsViewController else { + return + } + let thread = threads[indexPath.row] + commentsViewController.rootEntityId = thread._id + //commentsViewController.thread = thread + self.navigationController?.pushViewController(commentsViewController, animated: true) + } +} diff --git a/SEDaily-IOS/ForumThread.swift b/SEDaily-IOS/ForumThread.swift new file mode 100644 index 0000000..fa7689a --- /dev/null +++ b/SEDaily-IOS/ForumThread.swift @@ -0,0 +1,50 @@ +// +// ForumThread.swift +// SEDaily-IOS +// +// Created by jason on 4/24/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation +import SwiftMoment + +public struct ForumThread: BaseFeedItem { + var _id: String + let title: String + let content: String + let author: Author + let commentsCount: Int + let dateCreated: String + let dateLastAcitiy: String // An annoying bug that will require database migrations + miner update + var score: Int = 0 + let deleted: Bool + var downvoted: Bool? + var upvoted: Bool? + let podcastEpisode: PodcastLite? +} + +extension ForumThread { + func getPrettyTitle() -> String { + if podcastEpisode == nil { + return self.title + } + // Remove "Discuss: " from threads with posts defined: + return self.title.substring(from: self.title.index(self.title.startIndex, offsetBy: 9)) + } + func getCommentsSummary() -> String { + if commentsCount != 1 { + return "\(commentsCount) comments" + } else { + return "\(commentsCount) comment" + } + } + + func getDateLastActivityPretty() -> String { + return moment(self.dateLastAcitiy)?.fromNow() ?? "" + } + + func getDatedCreatedPretty() -> String { + return moment(self.dateCreated)?.fromNow() ?? "" + } +} diff --git a/SEDaily-IOS/ForumThreadCell.swift b/SEDaily-IOS/ForumThreadCell.swift new file mode 100644 index 0000000..b081c9e --- /dev/null +++ b/SEDaily-IOS/ForumThreadCell.swift @@ -0,0 +1,105 @@ +// +// ThreadCell.swift +// SEDaily-IOS +// +// Created by jason on 4/27/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class ForumThreadCell: UITableViewCell { + + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + @IBOutlet weak var commentsCountLabel: UILabel! + @IBOutlet weak var upVoteButton: UIButton! + @IBOutlet weak var scoreLabel: UILabel! + let networkService = API() + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + + let iconSize = UIView.getValueScaledByScreenHeightFor(baseValue: 34) + + upVoteButton.setIcon(icon: .fontAwesome(.angleUp), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .normal) + upVoteButton.setIcon(icon: .fontAwesome(.angleUp), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .selected) + + upVoteButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .selected) + } + + var thread: ForumThread? { + didSet { + + if let thread = thread { + + let author = thread.author + authorLabel.text = (author.name != nil) ? author.name : author.username + + titleLabel.text = thread.title + commentsCountLabel.text = thread.getCommentsSummary() + + dateLabel.text = thread.getDateLastActivityPretty() + + scoreLabel.text = "\(thread.score)" + if let upvoted = thread.upvoted { + upVoteButton.isSelected = upvoted + } else { + upVoteButton.isSelected = false + } + } + } + } + + @IBAction func upvotePressed(_ sender: UIButton) { + guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) + return + } + + // Immediately set UI to upvote + self.setUpvoteTo(!self.upVoteButton.isSelected) + if let thread = thread { + let entityId = thread._id + + networkService.upvoteForum(entityId: entityId, completion: { (success, active) in + guard success != nil else { return } + if success == true { + guard let active = active else { return } + self.addScore(active: active) + } + }) + } + } + + func setUpvoteTo(_ bool: Bool) { + self.thread?.upvoted = bool + self.upVoteButton.isSelected = bool + } + + func addScore(active: Bool) { + self.setUpvoteTo(active) + if let thread = thread { + guard active != false else { + self.setScoreTo(thread.score - 1) + return + } + self.setScoreTo(thread.score + 1) + } + } + + func setScoreTo(_ score: Int) { + guard self.thread?.score != score else { return } + self.thread?.score = score + self.scoreLabel.text = String(score) + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + +} diff --git a/SEDaily-IOS/ForumThreadLite.swift b/SEDaily-IOS/ForumThreadLite.swift new file mode 100644 index 0000000..a0ab476 --- /dev/null +++ b/SEDaily-IOS/ForumThreadLite.swift @@ -0,0 +1,14 @@ +// +// Thread.swift +// SEDaily-IOS +// +// Created by jason on 4/24/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +public struct ForumThreadLite: Codable { + let _id: String +// let author: Author // Is a string at times.. +} diff --git a/SEDaily-IOS/GeneralCollectionViewController.swift b/SEDaily-IOS/GeneralCollectionViewController.swift index 1e6d1ba..25b6592 100644 --- a/SEDaily-IOS/GeneralCollectionViewController.swift +++ b/SEDaily-IOS/GeneralCollectionViewController.swift @@ -7,185 +7,257 @@ // import UIKit -import KoalaTeaFlowLayout -import SDWebImage -private let reuseIdentifier = "Cell" - -class GeneralCollectionViewController: UICollectionViewController { - lazy var skeletonCollectionView: SkeletonCollectionView = { - return SkeletonCollectionView(frame: self.collectionView!.frame) - }() - - var type: PodcastTypes - var tabTitle: String - var tags: [Int] - var categories: [Int] - - // Paging Properties - var loading = false - let pageSize = 10 - let preloadMargin = 5 - - var lastLoadedPage = 0 - - var customTabBarItem: UITabBarItem! { - get { - switch type { - case .new: - return nil - case .recommended: - return UITabBarItem(title: L10n.tabBarJustForYou, image: #imageLiteral(resourceName: "activity_feed"), selectedImage: #imageLiteral(resourceName: "activity_feed_selected")) - case .top: - return UITabBarItem(tabBarSystemItem: .mostViewed, tag: 0) - } - } - } - - // ViewModelController - private let podcastViewModelController: PodcastViewModelController = PodcastViewModelController() - - init(collectionViewLayout layout: UICollectionViewLayout, - tags: [Int] = [], - categories: [PodcastCategoryIds] = [], - type: PodcastTypes = .new, - tabTitle: String = "") { - self.tabTitle = tabTitle - self.type = type - self.tags = tags - self.categories = categories.flatMap { $0.rawValue } - super.init(collectionViewLayout: layout) - self.tabBarItem = self.customTabBarItem - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - // Uncomment the following line to preserve selection between presentations - // self.clearsSelectionOnViewWillAppear = false - - // Register cell classes - self.collectionView?.register(PodcastCell.self, forCellWithReuseIdentifier: reuseIdentifier) - - let layout = KoalaTeaFlowLayout(cellWidth: UIView.getValueScaledByScreenWidthFor(baseValue: 158), - cellHeight: UIView.getValueScaledByScreenHeightFor(baseValue: 250), - topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 12), - leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 20), - cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 8)) - self.collectionView?.collectionViewLayout = layout - self.collectionView?.backgroundColor = .white - - // User Login observer - NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) - - self.collectionView?.addSubview(skeletonCollectionView) - } - - override func viewDidAppear(_ animated: Bool) { - // Make sure skeletonCollectionView is animating when the view is visible - if self.skeletonCollectionView.alpha != 0 { - self.skeletonCollectionView.collectionView.reloadData() - } - } - - override func viewDidDisappear(_ animated: Bool) { - //@TODO: Find a better way to manage cached Images - SDImageCache.shared().clearMemory() - } - - @objc func loginObserver() { - if self.type == .recommended { - self.podcastViewModelController.clearViewModels() - DispatchQueue.main.async { - self.collectionView?.reloadData() - } - } - self.getData(lastIdentifier: "", nextPage: 0) - } +import StatefulViewController - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } - - // MARK: UICollectionViewDataSource - - override func numberOfSections(in collectionView: UICollectionView) -> Int { - return 1 - } +private let reuseIdentifier = "Cell" - override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - if podcastViewModelController.viewModelsCount > 0 { - self.skeletonCollectionView.fadeOut(duration: 0.5, completion: nil) - } - if podcastViewModelController.viewModelsCount <= 0 { - // Load initial data - self.getData(lastIdentifier: "", nextPage: 0) - } - return podcastViewModelController.viewModelsCount - } +class GeneralCollectionViewController: UICollectionViewController, StatefulViewController, MainCoordinated { + var mainCoordinator: MainFlowCoordinator? + + lazy var skeletonCollectionView: SkeletonCollectionView = { + return SkeletonCollectionView(frame: self.collectionView!.frame) + }() + + var type: PodcastTypes + var tabTitle: String + var tags: [Int] + var categories: [Int] + + private var progressController = PlayProgressModelController() + + // Paging Properties + var loading = false + let pageSize = 10 + let preloadMargin = 5 + + var lastLoadedPage = 0 + var errorChecks = 0 + let maximumErrorChecks = 5 + + var customTabBarItem: UITabBarItem! { + switch type { + case .new: + // This is actually greatest hits right now + return UITabBarItem(tabBarSystemItem: .mostViewed, tag: 0) + case .recommended: + return UITabBarItem(title: L10n.tabBarJustForYou, image: #imageLiteral(resourceName: "activity_feed"), selectedImage: #imageLiteral(resourceName: "activity_feed_selected")) + case .top: + return UITabBarItem(tabBarSystemItem: .mostViewed, tag: 0) + } + } + + // ViewModelController + private let podcastViewModelController: PodcastViewModelController = PodcastViewModelController() + + init(collectionViewLayout layout: UICollectionViewLayout, + tags: [Int] = [], + categories: [PodcastCategoryIds] = [], + type: PodcastTypes = .new, + tabTitle: String = "") { + self.tabTitle = tabTitle + self.type = type + self.tags = tags + self.categories = categories.flatMap { $0.rawValue } + super.init(collectionViewLayout: layout) + self.tabBarItem = self.customTabBarItem + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Uncomment the following line to preserve selection between presentations + // self.clearsSelectionOnViewWillAppear = false + + + // Register cell classes + self.collectionView?.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + //hardcoded height + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light + + // User Login observer + NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + self.collectionView?.addSubview(skeletonCollectionView) + + switch type { + case .new: + Analytics2.newPodcastsListViewed(tabTitle: self.tabTitle) + case .recommended: + Analytics2.recommendedPodcastsListViewed(tabTitle: self.tabTitle) + case .top: + Analytics2.topPodcastsListViewed(tabTitle: self.tabTitle) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + progressController.retrieve() + self.collectionView?.reloadData() + } + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Make sure skeletonCollectionView is animating when the view is visible + if self.skeletonCollectionView.alpha != 0 { + self.skeletonCollectionView.collectionView.reloadData() + } + } + + @objc func loginObserver() { + self.podcastViewModelController.clearViewModels() + DispatchQueue.main.async { + self.collectionView?.reloadData() + } + self.getData(lastIdentifier: "", nextPage: 0) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // MARK: UICollectionViewDataSource + + override func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + if podcastViewModelController.viewModelsCount > 0 { + self.skeletonCollectionView.fadeOut(duration: 0.5, completion: nil) + } + if podcastViewModelController.viewModelsCount <= 0 { + // Load initial data + self.getData(lastIdentifier: "", nextPage: 0) + } + return podcastViewModelController.viewModelsCount + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + + // Configure the cell + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + + let upvoteService = UpvoteService(podcastViewModel: viewModel) + let bookmarkService = BookmarkService(podcastViewModel: viewModel) + + cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) + + cell.viewModel = viewModel + cell.upvoteService = upvoteService + cell.bookmarkService = bookmarkService + + cell.commentShowCallback = { [weak self] in + self?.commentsButtonPressed(viewModel) + + } - override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! PodcastCell - - // Configure the cell - if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { - cell.viewModel = viewModel - if let lastIndexPath = self.collectionView?.indexPathForLastItem { - if let lastItem = podcastViewModelController.viewModel(at: lastIndexPath.row) { - self.checkPage(currentIndexPath: indexPath, - lastIndexPath: lastIndexPath, - lastIdentifier: lastItem.uploadDateiso8601) - } - } - } - - return cell - } - - func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { - let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 - let preloadIndex = nextPage * self.pageSize - self.preloadMargin + if let lastIndexPath = self.collectionView?.indexPathForLastItem { + if let lastItem = podcastViewModelController.viewModel(at: lastIndexPath.row) { + self.checkPage(currentIndexPath: indexPath, + lastIndexPath: lastIndexPath, + lastIdentifier: lastItem.uploadDateiso8601) + } + } + } + + return cell + } + + func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { + let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 + let preloadIndex = nextPage * self.pageSize - self.preloadMargin + + if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { + // @TODO: Turn lastIdentifier into some T + self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage) + } + } + + func getData(lastIdentifier: String, nextPage: Int) { + guard self.loading == false else { return } + self.loading = true + podcastViewModelController.fetchData( + type: self.type.rawValue, + createdAtBefore: lastIdentifier, + tags: self.tags, + categories: self.categories, + onSuccess: { + self.errorChecks = 0 + self.loading = false + self.lastLoadedPage = nextPage + DispatchQueue.main.async { + self.collectionView?.reloadData() + }}, + onFailure: { (apiError) in + self.loading = false + self.errorChecks += 1 + log.error(apiError ?? "") + guard self.errorChecks <= self.maximumErrorChecks else { return } + self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage) + }) + } + + // MARK: UICollectionViewDelegate + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + let vc = EpisodeViewController() + vc.viewModel = viewModel + mainCoordinator?.configure(viewController: vc) + self.navigationController?.pushViewController(vc, animated: true) + } + } +} - if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { - // @TODO: Turn lastIdentifier into some T - self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage) - } - } - - func getData(lastIdentifier: String, nextPage: Int) { - guard self.loading == false else { return } - self.loading = true - podcastViewModelController.fetchData(type: self.type.rawValue, createdAtBefore: lastIdentifier, tags: self.tags, categories: self.categories, page: nextPage, onSucces: { - self.loading = false - self.lastLoadedPage = nextPage - DispatchQueue.main.async { - self.collectionView?.reloadData() - } - }) { (apiError) in - self.loading = false - log.error(apiError) - } - } +extension GeneralCollectionViewController { + private func viewModelDidChange(viewModel: PodcastViewModel) { + self.podcastViewModelController.update(with: viewModel) + } +} - // MARK: UICollectionViewDelegate - override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { - let vc = PodcastDetailViewController() - vc.model = viewModel - vc.delegate = self - self.navigationController?.pushViewController(vc, animated: true) - } - } +extension GeneralCollectionViewController { + func commentsButtonPressed(_ viewModel: PodcastViewModel) { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } } -extension GeneralCollectionViewController: PodcastDetailViewControllerDelegate { - func modelDidChange(viewModel: PodcastViewModel) { - self.podcastViewModelController.update(with: viewModel) - } +extension GeneralCollectionViewController { + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + viewModelDidChange(viewModel: viewModel) + } + } + } } diff --git a/SEDaily-IOS/GoogleService-Info.plist b/SEDaily-IOS/GoogleService-Info.plist new file mode 100644 index 0000000..576125f --- /dev/null +++ b/SEDaily-IOS/GoogleService-Info.plist @@ -0,0 +1,40 @@ + + + + + AD_UNIT_ID_FOR_BANNER_TEST + ca-app-pub-3940256099942544/2934735716 + AD_UNIT_ID_FOR_INTERSTITIAL_TEST + ca-app-pub-3940256099942544/4411468910 + CLIENT_ID + 171248788339-nfmqn8qa1nrdia90e7gojv2j8dca7nv9.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.171248788339-nfmqn8qa1nrdia90e7gojv2j8dca7nv9 + API_KEY + AIzaSyCoRD2vnnfswNPArVtRNQFUKHBXUvmg2CI + GCM_SENDER_ID + 171248788339 + PLIST_VERSION + 1 + BUNDLE_ID + koala-tea.SEDaily + PROJECT_ID + softwaredailyprod + STORAGE_BUCKET + softwaredailyprod.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:171248788339:ios:a92590b1eee7f0b7 + DATABASE_URL + https://softwaredailyprod.firebaseio.com + + \ No newline at end of file diff --git a/SEDaily-IOS/Haptics.swift b/SEDaily-IOS/Haptics.swift new file mode 100644 index 0000000..db40938 --- /dev/null +++ b/SEDaily-IOS/Haptics.swift @@ -0,0 +1,33 @@ +// +// Haptics.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/21/19. +// Copyright © 2019 Altalogy All rights reserved. +// +import UIKit + +enum FeedbackType { + case impact + case notification + case selection +} + +import Foundation + +class Haptics { + static func feedback(_ feedback: FeedbackType) { + switch feedback { + case .impact: + let impact = UIImpactFeedbackGenerator() + impact.impactOccurred() + case .notification: + let notification = UINotificationFeedbackGenerator() + notification.notificationOccurred(.success) + case .selection: + let selection = UISelectionFeedbackGenerator() + selection.selectionChanged() + } + } +} + diff --git a/SEDaily-IOS/HeaderView.swift b/SEDaily-IOS/HeaderView.swift index 7ef5c8f..1808928 100644 --- a/SEDaily-IOS/HeaderView.swift +++ b/SEDaily-IOS/HeaderView.swift @@ -9,143 +9,206 @@ import UIKit import SwiftIcons -protocol HeaderViewDelegate { +protocol HeaderViewDelegate: class { func modelDidChange(viewModel: PodcastViewModel) + func relatedLinksButtonPressed() + func updateBookmarked(active: Bool) + func commentsButtonPressed() } class HeaderView: UIView { - var delegate: HeaderViewDelegate? - - var model = PodcastViewModel() - + var iconSize = UIView.getValueScaledByScreenWidthFor(baseValue: 34) + + weak var delegate: HeaderViewDelegate? + weak var bookmarkDelegate:BookmarksDelegate? + weak var audioOverlayDelegate: AudioOverlayDelegate? + + var podcastViewModel = PodcastViewModel() + let titleLabel = UILabel() let dateLabel = UILabel() - + let playView = UIView() let playButton = UIButton() - + + let secondaryView = UIView() + let relatedLinksButton = UIButton() + let voteView = UIView() let stackView = UIStackView() + let commentsButton = UIButton() let upVoteButton = UIButton() let downVoteButton = UIButton() let scoreLabel = UILabel() - + + private var downloadButton = UIButton() + + + let downloadManager = OfflineDownloadsManager.sharedInstance + let networkService = API() + override init(frame: CGRect) { - super.init(frame: frame); - + super.init(frame: frame) self.performLayout() } - + + override func didMoveToSuperview() { + // This will "hide" the Play button, to make it clear it won't work if pressed. + if self.audioOverlayDelegate == nil { + playButton.alpha = 0.2 + } else { + playButton.alpha = 1.0 + } + } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented"); } - + override func performLayout() { let views = [ - titleLabel - ,dateLabel + titleLabel, + dateLabel ] - + self.addSubviews(views) - + self.backgroundColor = Stylesheet.Colors.base setupPlayView() - - titleLabel.snp.makeConstraints{ (make) in + setupSecondaryView() + + titleLabel.snp.makeConstraints { (make) in make.bottom.equalTo(playView.snp.top).offset(UIView.getValueScaledByScreenHeightFor(baseValue: -60)) make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) make.right.equalToSuperview().inset(UIView.getValueScaledByScreenHeightFor(baseValue: 15)) } - - dateLabel.snp.makeConstraints{ (make) in - make.top.equalTo(titleLabel.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 10)) + + dateLabel.snp.makeConstraints { (make) in + make.top.equalTo(titleLabel.snp.bottom).offset(UIView.getValueScaledByScreenHeightFor(baseValue: 15)) make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) make.right.equalToSuperview().inset(UIView.getValueScaledByScreenHeightFor(baseValue: 15)) } - + setupLabels() } - + func setupLabels() { + // This makes the post title and date pretty and large: titleLabel.font = UIFont(font: .helveticaNeue, size: UIView.getValueScaledByScreenWidthFor(baseValue: 20)) titleLabel.adjustsFontSizeToFitWidth = false titleLabel.minimumScaleFactor = 0.25 titleLabel.numberOfLines = 0 titleLabel.textColor = Stylesheet.Colors.white - + dateLabel.font = UIFont(font: .helveticaNeue, size: UIView.getValueScaledByScreenWidthFor(baseValue: 16)) dateLabel.adjustsFontSizeToFitWidth = false dateLabel.minimumScaleFactor = 0.25 dateLabel.numberOfLines = 1 dateLabel.textColor = Stylesheet.Colors.white } - + + func setupSecondaryView() { + self.addSubview(secondaryView) + + secondaryView.backgroundColor = UIColor.clear + + secondaryView.snp.makeConstraints { (make) in + make.bottom.equalTo(playView.snp.top) + make.right.left.equalToSuperview() + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 65)) + } + + // Add relatedLinksButton + secondaryView.addSubview(relatedLinksButton) + relatedLinksButton.setTitle(L10n.relatedLinks, for: .normal) + relatedLinksButton.setBackgroundColor(color: Stylesheet.Colors.baseLight, forState: .normal) + relatedLinksButton.addTarget(self, action: #selector(self.relatedLinksButtonPressed), for: .touchUpInside) + relatedLinksButton.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 4) + + relatedLinksButton.snp.makeConstraints { (make) in + make.centerY.equalToSuperview() + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 180)) + make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 35)) + } + } + func setupPlayView() { self.addSubview(playView) + + // The playView is the row with the Up / Down and Pink Playbutton playView.backgroundColor = Stylesheet.Colors.white - - playView.snp.makeConstraints{ (make) in + + playView.snp.makeConstraints { (make) in make.bottom.equalToSuperview() - make.width.equalToSuperview() + make.left.right.equalToSuperview() make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 65)) } - + playView.addSubview(playButton) playButton.setTitle(L10n.play, for: .normal) playButton.setBackgroundColor(color: Stylesheet.Colors.secondaryColor, forState: .normal) playButton.addTarget(self, action: #selector(self.playButtonPressed), for: .touchUpInside) playButton.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 4) - - playButton.snp.makeConstraints{ (make) in + + playButton.snp.makeConstraints { (make) in make.centerY.equalToSuperview() make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 84)) make.height.equalTo(UIView.getValueScaledByScreenHeightFor(baseValue: 42)) } - + playView.addSubview(voteView) - voteView.snp.makeConstraints{ (make) in + voteView.snp.makeConstraints { (make) in make.centerY.equalToSuperview() - make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) - make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: (35 * 3))) + make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: (35 * 4))) make.height.equalToSuperview() } - + voteView.addSubview(stackView) - stackView.snp.makeConstraints{ (make) in + stackView.snp.makeConstraints { (make) in make.edges.equalToSuperview() } stackView.axis = .horizontal stackView.alignment = .fill stackView.distribution = .fillEqually - +// stackView.addArrangedSubview(commentsButton) + stackView.addArrangedSubview(downVoteButton) stackView.addArrangedSubview(scoreLabel) stackView.addArrangedSubview(upVoteButton) - + scoreLabel.textAlignment = .center scoreLabel.baselineAdjustment = .alignCenters scoreLabel.font = UIFont(font: .helveticaNeue, size: UIView.getValueScaledByScreenWidthFor(baseValue: 24)) - let iconSize = UIView.getValueScaledByScreenHeightFor(baseValue: 35) downVoteButton.setIcon(icon: .fontAwesome(.thumbsODown), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .normal) downVoteButton.setIcon(icon: .fontAwesome(.thumbsDown), iconSize: iconSize, color: Stylesheet.Colors.base, forState: .selected) downVoteButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .selected) downVoteButton.addTarget(self, action: #selector(self.downVoteButtonPressed), for: .touchUpInside) - + upVoteButton.setIcon(icon: .fontAwesome(.thumbsOUp), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .normal) upVoteButton.setIcon(icon: .fontAwesome(.thumbsUp), iconSize: iconSize, color: Stylesheet.Colors.base, forState: .selected) upVoteButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .selected) upVoteButton.addTarget(self, action: #selector(self.upvoteButtonPressed), for: .touchUpInside) } - + func setupHeader(model: PodcastViewModel) { - self.model = model + self.podcastViewModel = model + if self.podcastViewModel.thread != nil { + commentsButton.isHidden = false + } else { + commentsButton.isHidden = true + } self.titleLabel.text = model.podcastTitle self.dateLabel.text = model.getLastUpdatedAsDate()?.dateString() ?? "" self.scoreLabel.text = model.score.string - - upVoteButton.isSelected = self.model.isUpvoted - downVoteButton.isSelected = self.model.isDownvoted - self.scoreLabel.text = String(self.model.score) + + commentsButton.isSelected = false + upVoteButton.isSelected = self.podcastViewModel.isUpvoted + downVoteButton.isSelected = self.podcastViewModel.isDownvoted + self.scoreLabel.text = String(self.podcastViewModel.score) + + self.setupDownloadButton() + self.setupCommentsButton() } } @@ -153,22 +216,32 @@ extension HeaderView { @objc func playButtonPressed() { //@TODO: Switch button and/or stop if playing - // Podcast model checks here - AudioViewManager.shared.setupManager(podcastModel: model) + self.audioOverlayDelegate?.animateOverlayIn() + self.audioOverlayDelegate?.playAudio(podcastViewModel: self.podcastViewModel) + + AskForReview.triggerEvent() + } + @objc func relatedLinksButtonPressed() { + self.delegate?.relatedLinksButtonPressed() + } + + @objc func commentsButtonPressed() { + self.delegate?.commentsButtonPressed() } - + @objc func upvoteButtonPressed() { guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) return } - + // Immediately set UI to upvote self.setUpvoteTo(!self.upVoteButton.isSelected) self.setDownvoteTo(false) - - let podcastId = model._id - API.sharedInstance.upvotePodcast(podcastId: podcastId, completion: { (success, active) in + + let podcastId = podcastViewModel._id + + networkService.upvotePodcast(podcastId: podcastId, completion: { (success, active) in guard success != nil else { return } if success == true { guard let active = active else { return } @@ -176,19 +249,19 @@ extension HeaderView { } }) } - + @objc func downVoteButtonPressed() { guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) return } - + // Immediately set UI to downvote self.setUpvoteTo(false) self.setDownvoteTo(!self.downVoteButton.isSelected) - - let podcastId = model._id - API.sharedInstance.downvotePodcast(podcastId: podcastId, completion: { (success, active) in + + let podcastId = podcastViewModel._id + networkService.downvotePodcast(podcastId: podcastId, completion: { (success, active) in guard success != nil else { return } if success == true { // Switch if active @@ -197,42 +270,156 @@ extension HeaderView { } }) } - + func addScore(active: Bool) { self.setUpvoteTo(active) guard active != false else { - self.setScoreTo(self.model.score - 1) - self.delegate?.modelDidChange(viewModel: self.model) + self.setScoreTo(self.podcastViewModel.score - 1) + self.delegate?.modelDidChange(viewModel: self.podcastViewModel) return } - self.setScoreTo(self.model.score + 1) - self.delegate?.modelDidChange(viewModel: self.model) + self.setScoreTo(self.podcastViewModel.score + 1) + self.delegate?.modelDidChange(viewModel: self.podcastViewModel) } - + func subtractScore(active: Bool) { self.setDownvoteTo(active) guard active != false else { - self.setScoreTo(self.model.score + 1) - self.delegate?.modelDidChange(viewModel: self.model) + self.setScoreTo(self.podcastViewModel.score + 1) + self.delegate?.modelDidChange(viewModel: self.podcastViewModel) return } - self.setScoreTo(self.model.score - 1) - self.delegate?.modelDidChange(viewModel: self.model) + self.setScoreTo(self.podcastViewModel.score - 1) + self.delegate?.modelDidChange(viewModel: self.podcastViewModel) } - + func setUpvoteTo(_ bool: Bool) { - self.model.isUpvoted = bool + self.podcastViewModel.isUpvoted = bool self.upVoteButton.isSelected = bool } - + func setDownvoteTo(_ bool: Bool) { - self.model.isDownvoted = bool + self.podcastViewModel.isDownvoted = bool self.downVoteButton.isSelected = bool } - + func setScoreTo(_ score: Int) { - guard self.model.score != score else { return } - self.model.score = score + guard self.podcastViewModel.score != score else { return } + self.podcastViewModel.score = score self.scoreLabel.text = String(score) } } + +extension HeaderView { + @objc private func downloadButtonPressed() { + switch self.downloadButton.isSelected { + case true: + self.deletePodcast() + case false: + self.savePodcast() + if UserManager.sharedInstance.isCurrentUserLoggedIn() == true { + self.bookmarkDelegate?.bookmarkPodcast() + } + } + } + + private func savePodcast() { + guard !self.downloadButton.isSelected else { return } + self.downloadButton.isSelected = true + + self.playButton.isUserInteractionEnabled = false + + let podcastId = self.podcastViewModel._id + + self.downloadManager.save( + podcast: self.podcastViewModel, + onProgress: { progress in + // Show progress + let progressAsInt = Int((progress * 100).rounded()) + self.playButton.setTitle(String(progressAsInt) + "%", for: .normal)}, + onSuccess: { + // Show success by changing download + self.delegate?.modelDidChange(viewModel: self.podcastViewModel) +// self.audioOverlayDelegate?.animateOverlayIn() +// self.audioOverlayDelegate?.playAudio(podcastViewModel: self.podcastViewModel) +// self.audioOverlayDelegate?.pauseAudio() + self.playButton.setTitle("Play", for: .normal) + self.playButton.isUserInteractionEnabled = true}, + onFailure: { error in + self.playButton.setTitle("Play", for: .normal) + self.playButton.isUserInteractionEnabled = true + + guard let error = error else { return } + // Alert Error + Helpers.alertWithMessage(title: error.localizedDescription.capitalized, message: "")}) + } + + private func deletePodcast() { + guard self.downloadButton.isSelected else { return } + + let alert = UIAlertController(title: "Are you sure you want to delete this podcast?", message: nil, preferredStyle: .alert) + + alert.addAction(title: "YEP! Delete it please.", style: .destructive, isEnabled: true) { _ in + self.downloadManager.deletePodcast(podcast: self.podcastViewModel) { + print("Successfully deleted") + } + + self.downloadButton.isSelected = false + self.playButton.setTitle("Play", for: .normal) + self.playButton.isUserInteractionEnabled = true + } + + let noAction = UIAlertAction(title: "Oh no actually...", style: .cancel, handler: nil) + alert.addAction(noAction) + + if var topController = UIApplication.shared.keyWindow?.rootViewController { + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + + guard !(topController is UIAlertController) else { + // There's already a alert preseneted + return + } + + topController.present(alert, animated: true, completion: nil) + } + } + + private func setupDownloadButton() { + + self.downloadButton.addTarget(self, action: #selector(self.downloadButtonPressed), for: .touchUpInside) + self.downloadButton.setIcon( + icon: .fontAwesome(.cloudDownload), + iconSize: iconSize, + color: Stylesheet.Colors.secondaryColor, + forState: .normal) + self.downloadButton.setIcon( + icon: .fontAwesome(.timesCircle), + iconSize: iconSize, + color: .red, + forState: .selected) + self.downloadButton.isSelected = self.podcastViewModel.isDownloaded + + self.playView.addSubview(self.downloadButton) + + let rightInset = UIView.getValueScaledByScreenWidthFor(baseValue: 20) + downloadButton.snp.makeConstraints { (make) in + make.right.equalTo(self.playButton.snp.left).inset(-rightInset) + make.centerY.equalTo(self.playButton.snp.centerY) + } + } + + private func setupCommentsButton() { + commentsButton.setIcon(icon: .fontAwesome(.commentO), iconSize: iconSize, color: Stylesheet.Colors.offBlack, forState: .normal) + commentsButton.addTarget(self, action: #selector(self.commentsButtonPressed), for: .touchUpInside) + + self.playView.addSubview(self.commentsButton) + + let rightInset = UIView.getValueScaledByScreenWidthFor(baseValue: 20) + commentsButton.snp.makeConstraints { (make) in + make.right.equalTo(self.downloadButton.snp.left).inset(-rightInset) + make.centerY.equalTo(self.downloadButton.snp.centerY) + } + } +} diff --git a/SEDaily-IOS/Helpers.swift b/SEDaily-IOS/Helpers.swift index 6d5c2cd..cef9947 100644 --- a/SEDaily-IOS/Helpers.swift +++ b/SEDaily-IOS/Helpers.swift @@ -6,173 +6,211 @@ // Copyright © 2017 Koala Tea. All rights reserved. // - import UIKit import SwifterSwift extension Helpers { - static var alert: UIAlertController! - - enum Alerts { - static let error = L10n.genericError - static let success = L10n.genericSuccess - } - - enum Messages { - static let emailEmpty = L10n.alertMessageEmailEmpty - static let passwordEmpty = L10n.alertMessagePasswordEmpty - static let passwordConfirmEmpty = L10n.alertMessagePasswordConfirmEmpty - static let emailWrongFormat = L10n.alertMessageEmailWrongFormat - static let passwordNotLongEnough = L10n.alertMessagePasswordNotLongEnough - static let passwordsDonotMatch = L10n.alertMessagePasswordsDonotMatch - static let firstNameEmpty = L10n.alertMessageFirstNameEmpty - static let firstNameNotLongEnough = L10n.alertMessageFirstNameNotLongEnough - static let lastNameEmpty = L10n.alertMessageLastNameEmpty - static let lastNameNotLongEnough = L10n.alertMessageLastNameNotLongEnough - static let pleaseLogin = L10n.alertMessagePleaseLogin - static let issueWithUserToken = L10n.alertMessageIssueWithUserToken - static let youMustLogin = L10n.alertMessageYouMustLogin - static let logoutSuccess = L10n.alertMessageLogoutSuccess - } + static var alert: UIAlertController! + + enum Alerts { + static let error = L10n.genericError + static let success = L10n.genericSuccess + } + + enum Messages { + static let emailEmpty = L10n.alertMessageEmailEmpty + static let usernameEmpty = L10n.alertMessageUsernameEmpty + static let passwordEmpty = L10n.alertMessagePasswordEmpty + static let passwordConfirmEmpty = L10n.alertMessagePasswordConfirmEmpty + static let emailWrongFormat = L10n.alertMessageEmailWrongFormat + static let passwordNotLongEnough = L10n.alertMessagePasswordNotLongEnough + static let passwordsDonotMatch = L10n.alertMessagePasswordsDonotMatch + static let firstNameEmpty = L10n.alertMessageFirstNameEmpty + static let firstNameNotLongEnough = L10n.alertMessageFirstNameNotLongEnough + static let lastNameEmpty = L10n.alertMessageLastNameEmpty + static let lastNameNotLongEnough = L10n.alertMessageLastNameNotLongEnough + static let pleaseLogin = L10n.alertMessagePleaseLogin + static let issueWithUserToken = L10n.alertMessageIssueWithUserToken + static let youMustLogin = L10n.alertMessageYouMustLogin + static let logoutSuccess = L10n.alertMessageLogoutSuccess + static let fieldEmpty = L10n.fieldEmpty + static let somethingWentWrong = L10n.somethingWentWrong + + } } class Helpers { - - static func alertWithMessage(title: String!, message: String!, completionHandler: (() -> ())? = nil) { - //@TODO: Guard if there's already an alert message - if var topController = UIApplication.shared.keyWindow?.rootViewController { - while let presentedViewController = topController.presentedViewController { - topController = presentedViewController - } - - guard !(topController is UIAlertController) else { - // There's already a alert preseneted - return - } - - alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertControllerStyle.alert) - alert.addAction(UIAlertAction(title: L10n.genericOkay, style: UIAlertActionStyle.default, handler: nil)) - topController.present(alert, animated: true, completion: nil) - completionHandler?() - } - } - - class func isValidEmailAddress(emailAddressString: String) -> Bool { - - var returnValue = true - let emailRegEx = "[A-Z0-9a-z.-_]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,3}" - - do { - let regex = try NSRegularExpression(pattern: emailRegEx) - let nsString = emailAddressString as NSString - let results = regex.matches(in: emailAddressString, range: NSRange(location: 0, length: nsString.length)) - - if results.count == 0 - { - returnValue = false - } - - } catch let error as NSError { - print("invalid regex: \(error.localizedDescription)") - returnValue = false - } - - return returnValue - } - - static func hmsFrom(seconds: Int, completion: @escaping (_ hours: Int, _ minutes: Int, _ seconds: Int)->()) { - - completion(seconds / 3600, (seconds % 3600) / 60, (seconds % 3600) % 60) - - } - - static func getStringFrom(seconds: Int) -> String { - - return seconds < 10 ? "0\(seconds)" : "\(seconds)" - } - - /* - A formatter for individual date components used to provide an appropriate - value for the `startTimeLabel` and `durationLabel`. - */ - static let timeRemainingFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.zeroFormattingBehavior = .pad - formatter.allowedUnits = [.minute, .second] - - return formatter - }() - - static func createTimeString(time: Float) -> String { - let components = NSDateComponents() - components.second = Int(max(0.0, time)) - - return timeRemainingFormatter.string(from: components as DateComponents)! - } + static func alertWithMessage(title: String!, message: String!, completionHandler: (() -> Void)? = nil) { + //@TODO: Guard if there's already an alert message + if var topController = UIApplication.shared.keyWindow?.rootViewController { + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + + guard !(topController is UIAlertController) else { + // There's already a alert preseneted + return + } + + alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertControllerStyle.alert) + alert.addAction(UIAlertAction(title: L10n.genericOkay, style: UIAlertActionStyle.default, handler: nil)) + topController.present(alert, animated: true, completion: nil) + completionHandler?() + } + } + + static func alertWithMessageCustomAction(title: String!, message: String!, actionTitle: String, completionHandler: (() -> Void)? = nil) { + //@TODO: Guard if there's already an alert message + if var topController = UIApplication.shared.keyWindow?.rootViewController { + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + + guard !(topController is UIAlertController) else { + // There's already a alert preseneted + return + } + + alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertControllerStyle.alert) + alert.addAction(UIAlertAction(title: actionTitle, style: .default, handler: { (_) in + completionHandler?() + })) + let noAction = UIAlertAction(title: L10n.cancelButtonTitle, style: .cancel, handler: nil) + alert.addAction(noAction) + topController.present(alert, animated: true, completion: nil) + } + } + + class func isValidEmailAddress(emailAddressString: String) -> Bool { + + var returnValue = true + let emailRegEx = "^[A-Z0-9a-z._-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$" + + do { + let regex = try NSRegularExpression(pattern: emailRegEx) + let nsString = emailAddressString as NSString + let results = regex.matches(in: emailAddressString, range: NSRange(location: 0, length: nsString.length)) + + if results.count == 0 { + returnValue = false + } + + } catch let error as NSError { + print("invalid regex: \(error.localizedDescription)") + returnValue = false + } + + return returnValue + } + + static func hmsFrom(seconds: Int, completion: @escaping (_ hours: Int, _ minutes: Int, _ seconds: Int) -> Void) { + + completion(seconds / 3600, (seconds % 3600) / 60, (seconds % 3600) % 60) + + } + + static func getStringFrom(seconds: Int) -> String { + + return seconds < 10 ? "0\(seconds)" : "\(seconds)" + } + + + + static func createTimeString(time: Float, units: NSCalendar.Unit ) -> String { + /* + A formatter for individual date components used to provide an appropriate + value for the `startTimeLabel`, `durationLabel` and `timeLeft label`. + */ + let timeRemainingFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.zeroFormattingBehavior = .pad + formatter.allowedUnits = units + + return formatter + }() + let components = NSDateComponents() + components.second = Int(max(0.0, time)) + + return timeRemainingFormatter.string(from: components as DateComponents)! + } } extension Helpers { - //MARK: Date - class func formatDate(dateString: String) -> String { - let startDate = Date(iso8601String: dateString)! - let startMonth = startDate.monthName() - let day = startDate.day.string - let year = startDate.year.string - return startMonth + " " + day + ", " + year - } + class func formatDate(dateString: String) -> String { + let startDate = Date(iso8601String: dateString)! + let startMonth = startDate.monthName() + let day = startDate.day.string + let year = startDate.year.string + return startMonth + " " + day + ", " + year + } + + class func getScreenWidth() -> CGFloat { + return UIScreen.main.bounds.width + } + + class func getEpisodeCellHeight() -> CGFloat { + if UIDevice().userInterfaceIdiom == .phone { + switch UIScreen.main.nativeBounds.height { + case 2436: + return 200 + default: + return 250 + } + } + return 250 + } } import SwiftSoup public extension String { - // Decodes string with html encoding. - // This is very fast - var htmlDecoded: String { - do { - let html = self - let doc: Document = try SwiftSoup.parse(html) - return try doc.text() - } catch { - return self - } - } + // Decodes string with html encoding. + // This is very fast + var htmlDecoded: String { + do { + let html = self + let doc: Document = try SwiftSoup.parse(html) + return try doc.text() + } catch { + return self + } + } } extension String { - // Decode HTML while keeping attributes like "/n" and bulleted lists - // This is a bit slow - var htmlDecodedWithSomeEntities: String? { - guard let data = self.data(using: .utf8) else { - return nil - } - let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ - NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html, - NSAttributedString.DocumentReadingOptionKey.characterEncoding: String.Encoding.utf8.rawValue - ] - do { - - let attributedString = try NSAttributedString(data: data, options: options, documentAttributes: nil) - return attributedString.string - } - catch { - return nil - } - } + // Decode HTML while keeping attributes like "/n" and bulleted lists + // This is a bit slow + var htmlDecodedWithSomeEntities: String? { + guard let data = self.data(using: .utf8) else { + return nil + } + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [ + NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html, + NSAttributedString.DocumentReadingOptionKey.characterEncoding: String.Encoding.utf8.rawValue + ] + do { + + let attributedString = try NSAttributedString(data: data, options: options, documentAttributes: nil) + return attributedString.string + } catch { + return nil + } + } } class TextField: UITextField { - - let padding = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10); - - override func textRect(forBounds bounds: CGRect) -> CGRect { - return UIEdgeInsetsInsetRect(bounds, padding) - } - - override func placeholderRect(forBounds bounds: CGRect) -> CGRect { - return UIEdgeInsetsInsetRect(bounds, padding) - } - - override func editingRect(forBounds bounds: CGRect) -> CGRect { - return UIEdgeInsetsInsetRect(bounds, padding) - } + + let padding = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + + override func textRect(forBounds bounds: CGRect) -> CGRect { + return UIEdgeInsetsInsetRect(bounds, padding) + } + + override func placeholderRect(forBounds bounds: CGRect) -> CGRect { + return UIEdgeInsetsInsetRect(bounds, padding) + } + + override func editingRect(forBounds bounds: CGRect) -> CGRect { + return UIEdgeInsetsInsetRect(bounds, padding) + } } diff --git a/SEDaily-IOS/HtmlHelper.swift b/SEDaily-IOS/HtmlHelper.swift new file mode 100644 index 0000000..f1afacb --- /dev/null +++ b/SEDaily-IOS/HtmlHelper.swift @@ -0,0 +1,80 @@ +// +// HtmlHelper.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/25/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import UIKit + +class HtmlHelper { + + class func getMeta(html:String)->String { + return removePowerPressPlayerTags(html: html) + } + + class func removePowerPressPlayerTags(html: String) -> String { + var modifiedHtml = html + guard let powerPressPlayerRange = modifiedHtml.range(of: "") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(powerPressPlayerRange) + + ///////////////////////// + guard let divStartRange = modifiedHtml.range(of: "
") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(divStartRange.lowerBound..") else { + return modifiedHtml + } + guard let pEndRange = modifiedHtml.range(of: "

") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(pStartRange.lowerBound.. String { + var modifiedHtml = html + guard let divStartRange = modifiedHtml.range(of: "") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(divStartRange.lowerBound.. String { + + let margin = UIView.getValueScaledByScreenWidthFor(baseValue: 15.0) + let fontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 15.0) + + return "\(html)" + } + + class func addWidthAdjustment(html: String) -> String { + return "
\(html)" + } + + class func addScaleMeta(html: String) -> String { + return "\(html)" + } + + + class func getHTML(html: String)-> String { + return removeImage(html: addScaleMeta(html: addStyling(html: removePowerPressPlayerTags(html: html)))) + } + +} + diff --git a/SEDaily-IOS/Info.plist b/SEDaily-IOS/Info.plist index fb1d115..ff8a98b 100644 --- a/SEDaily-IOS/Info.plist +++ b/SEDaily-IOS/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.1 + 2.0.1 CFBundleVersion - 15 + 5 Fabric APIKey @@ -36,6 +36,8 @@ ITSAppUsesNonExemptEncryption + LSApplicationCategoryType + LSRequiresIPhoneOS NSAppTransportSecurity @@ -43,9 +45,19 @@ NSAllowsArbitraryLoads + UIAppFonts + + Roboto-Bold.ttf + OpenSans-Semibold.ttf + OpenSans-Regular.ttf + Roboto-Light.ttf + Roboto-Regular.ttf + OpenSans-Light.ttf + UIBackgroundModes audio + fetch UILaunchStoryboardName LaunchScreen diff --git a/SEDaily-IOS/ItemCollectionViewCell.swift b/SEDaily-IOS/ItemCollectionViewCell.swift new file mode 100644 index 0000000..7399e6d --- /dev/null +++ b/SEDaily-IOS/ItemCollectionViewCell.swift @@ -0,0 +1,355 @@ +// +// ItemCollectionViewCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/18/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import AVFoundation + +import UIKit +import SnapKit +import KTResponsiveUI +import Skeleton +import Kingfisher + +class ItemCollectionViewCell: UICollectionViewCell { + var imageView: UIImageView! + var imageOverlay: UIView! + var titleLabel: UILabel! + var miscDetailsLabel: UILabel! + var descriptionLabel: UILabel! + + var actionView: ActionView! + + var commentShowCallback: (()-> Void) = {} + + let upvoteCountLabel: UILabel = UILabel() + + let upvoteStackView: UIStackView = UIStackView() + + let progressBar: UIProgressView = UIProgressView() + + // MARK: Skeleton + var skeletonImageView: GradientContainerView! + var skeletonTitleLabel: GradientContainerView! + var skeletontimeDayLabel: GradientContainerView! + var skeletonTitleLabelNextLine: GradientContainerView! + var skeletontimeDayLabelNextLine: GradientContainerView! + + + + var viewModel: PodcastViewModel = PodcastViewModel() { + willSet { + guard newValue != self.viewModel else { return } + } + didSet { + updateUI() + } + } + + var upvoteService: UpvoteService? + var bookmarkService: BookmarkService? + + var playProgress: PlayProgress? + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + setupButtonsTargets() + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + //MARK: Button handlers + + private func setupButtonsTargets() { + actionView.upvoteButton.addTarget(self, action: #selector(ItemCollectionViewCell.upvoteTapped), for: .touchUpInside) + actionView.bookmarkButton.addTarget(self, action: #selector(ItemCollectionViewCell.bookmarkTapped), for: .touchUpInside) + actionView.commentButton.addTarget(self, action: #selector(ItemCollectionViewCell.commentTapped), for: .touchUpInside) + } + + @objc func upvoteTapped() { + + Haptics.feedback(.impact) + upvoteService?.UIDelegate = self + upvoteService?.upvote() + } + + @objc func bookmarkTapped() { + + Haptics.feedback(.impact) + bookmarkService?.UIDelegate = self + bookmarkService?.setBookmark() + } + + @objc func commentTapped() { + + Haptics.feedback(.impact) + commentShowCallback() + } + + func setupSkeletonCell() { + self.setupSkeletonView() + self.slide(to: .right) + } +} + +extension ItemCollectionViewCell: UpvoteServiceUIDelegate { + func upvoteUIDidChange(isUpvoted: Bool, score: Int) { + actionView.upvoteButton.isSelected = isUpvoted + actionView.upvoteCountLabel.text = String(score) + updateLabelStyle() + } + + func upvoteUIImmediateUpdate() { + guard let tempScore = Int(actionView.upvoteCountLabel.text ?? "0") else { return } + actionView.upvoteCountLabel.text = actionView.upvoteButton.isSelected ? String(tempScore - 1) : String(tempScore + 1) + actionView.upvoteButton.isSelected = !actionView.upvoteButton.isSelected + updateLabelStyle() + } +} + +extension ItemCollectionViewCell { + func updateLabelStyle() { + actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark + actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: 13) : UIFont(name: "OpenSans", size: 13) + } +} + +extension ItemCollectionViewCell: BookmarkServiceUIDelegate { + func bookmarkUIDidChange(isBookmarked: Bool) { + actionView.bookmarkButton.isSelected = isBookmarked + } + func bookmarkUIImmediateUpdate() { + actionView.bookmarkButton.isSelected = !actionView.bookmarkButton.isSelected + } +} + +extension ItemCollectionViewCell { + private func setupLayout() { + + backgroundColor = .white + + func setupImageView() { + imageView = UIImageView() + contentView.addSubview(imageView) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 5) + imageView.kf.indicatorType = .activity + + imageOverlay = UIView() + contentView.addSubview(imageOverlay) + imageOverlay.clipsToBounds = true + imageOverlay.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 5) + imageOverlay.backgroundColor = Stylesheet.Colors.lightTransparent + } + + func setupLabels() { + titleLabel = UILabel() + contentView.addSubview(titleLabel) + titleLabel.numberOfLines = 3 + titleLabel.font = UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 17)) + titleLabel.textColor = Stylesheet.Colors.dark + + miscDetailsLabel = UILabel() + contentView.addSubview(miscDetailsLabel) + miscDetailsLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 11)) + miscDetailsLabel.textColor = Stylesheet.Colors.dark + + descriptionLabel = UILabel() + descriptionLabel.numberOfLines = 2 + descriptionLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + descriptionLabel.textColor = Stylesheet.Colors.dark + contentView.addSubview(descriptionLabel) + } + + + func setupProgressBar() { + progressBar.progressTintColor = Stylesheet.Colors.base + progressBar.trackTintColor = Stylesheet.Colors.gray + progressBar.transform = progressBar.transform.scaledBy(x: 1, y: 1) + progressBar.isHidden = true + contentView.addSubview(progressBar) + } + + func setupActionView() { + actionView = ActionView() + actionView.setupComponents(superview: contentView) + } + + + func setupConstraints() { + imageView.snp.makeConstraints { (make) -> Void in + make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:15)) + make.top.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:10)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 80)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 80)) + } + + + imageOverlay.snp.makeConstraints { (make) -> Void in + make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:15)) + make.top.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:10)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue:80)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue:80)) + } + + titleLabel.snp.makeConstraints { (make) -> Void in + make.top.equalTo(imageView) + make.rightMargin.equalTo(contentView).inset(UIView.getValueScaledByScreenWidthFor(baseValue:15.0)) + make.left.equalTo(imageView.snp.right).offset(UIView.getValueScaledByScreenWidthFor(baseValue:10.0)) + } + + miscDetailsLabel.snp.makeConstraints { (make) -> Void in + make.top.equalTo(titleLabel.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue:5.0)) + make.left.equalTo(titleLabel) + } + + descriptionLabel.snp.makeConstraints { (make) -> Void in + make.top.equalTo(imageView.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue:10.0)) + make.rightMargin.equalTo(contentView).inset(UIView.getValueScaledByScreenWidthFor(baseValue:15.0)) + make.left.equalTo(imageView) + } + actionView.setupContraints() + actionView.actionStackView.snp.makeConstraints { (make) -> Void in + make.bottom.equalTo(contentView) + make.left.equalTo(imageView) + } + + + progressBar.snp.makeConstraints { (make) -> Void in + make.width.equalTo(contentView) + make.rightMargin.equalTo(contentView) + make.leftMargin.equalTo(contentView) + make.bottom.equalTo(contentView) + } + } + + setupImageView() + setupLabels() + setupProgressBar() + setupActionView() + setupConstraints() + } +} + +extension ItemCollectionViewCell { + private func updateUI() { + + self.titleLabel.text = viewModel.podcastTitle + + func loadImageView(imageURL: URL?) { + imageView.kf.cancelDownloadTask() + guard let imageURL = imageURL else { + imageView.image = #imageLiteral(resourceName: "SEDaily_Logo") + return + } + imageView.kf.setImage(with: imageURL, options: [.transition(.fade(0.2))]) + } + + func setupMiscDetailsLabel(timeLength: Int?, date: Date?, isDownloaded: Bool) { + let dateString = date?.dateString() ?? "" + let timeLeftString = isProgressSet() ? createTimeLeftString() : "" + miscDetailsLabel.text = dateString + timeLeftString + } + + func createTimeLeftString()->String { + return " · " + Helpers.createTimeString(time: playProgress?.timeLeft ?? 0.0, units: [.minute]) + " " + L10n.timeLeft + } + + func setupDescriptionLabel() { + var str: String! + // Due to asynchronuous nature of decoding html content, this is a better way to do it + DispatchQueue.global(qos: .background).async { [weak self] in + str = self?.viewModel.podcastDescription + DispatchQueue.main.async { + + self?.descriptionLabel.text = str + } + } + + } + + func updateUpvote() { + actionView.upvoteCountLabel.text = String(viewModel.score) + actionView.upvoteButton.isSelected = viewModel.isUpvoted + actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark + actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: 13) : UIFont(name: "OpenSans", size: 13) + } + + func updateBookmark() { + actionView.bookmarkButton.isSelected = viewModel.isBookmarked + } + + func updateProgressBar() { + guard let playProgress = playProgress else { return } + if isProgressSet() { + progressBar.progress = playProgress.progressFraction + progressBar.isHidden = false + } else { + progressBar.isHidden = true + } + } + + func isProgressSet()->Bool { + guard let playProgress = playProgress else { return false } + return playProgress.progressFraction > Float(0.005) + } + + loadImageView(imageURL: viewModel.featuredImageURL) + viewModel.getLastUpdatedAsDateWith { [weak self] (date) in + guard let strongSelf = self else { return } + setupMiscDetailsLabel(timeLength: nil, date: date, isDownloaded: strongSelf.viewModel.isDownloaded) + } + updateProgressBar() + setupDescriptionLabel() + updateUpvote() + updateBookmark() + } +} + +extension ItemCollectionViewCell { + func setupSkeletonView() { + + func scale(_ value: CGFloat)-> CGFloat { + return UIView.getValueScaledByScreenWidthFor(baseValue: value) + } + + skeletonImageView = GradientContainerView(frame: CGRect(x: scale(15.0), y: scale(10.0), width: scale(80.0), height: scale(80.0))) + skeletonImageView.cornerRadius = self.imageView.cornerRadius + skeletonImageView.backgroundColor = UIColor(red: 0.87, green: 0.87, blue: 0.87, alpha: 1.0) + contentView.addSubview(skeletonImageView) + skeletonTitleLabel = GradientContainerView(frame: CGRect(x: scale(100.0), y: scale(10.0), width: scale(200.0), height: scale(15.0))) + skeletonTitleLabelNextLine = GradientContainerView(frame: CGRect(x: scale(100.0), y: scale(40.0), width: scale(200.0), height: scale(15.0))) + contentView.addSubview(skeletonTitleLabel) + contentView.addSubview(skeletonTitleLabelNextLine) + skeletontimeDayLabel = GradientContainerView(origin: skeletonImageView.bottomLeftPoint(), topInset: scale(10), width: scale(350), height: scale(10)) + skeletontimeDayLabelNextLine = GradientContainerView(origin: skeletonImageView.bottomLeftPoint(), topInset: scale(25), width: scale(350), height: scale(10)) + contentView.addSubview(skeletontimeDayLabel) + contentView.addSubview(skeletontimeDayLabelNextLine) + + let baseColor = skeletonImageView.backgroundColor! + let gradients = baseColor.getGradientColors(brightenedBy: 1.07) + skeletonImageView.gradientLayer.colors = gradients + skeletonTitleLabel.gradientLayer.colors = gradients + skeletonTitleLabelNextLine.gradientLayer.colors = gradients + skeletontimeDayLabel.gradientLayer.colors = gradients + skeletontimeDayLabelNextLine.gradientLayer.colors = gradients + } +} + +extension ItemCollectionViewCell: GradientsOwner { + var gradientLayers: [CAGradientLayer] { + return [skeletonImageView.gradientLayer, + skeletonTitleLabel.gradientLayer, + skeletonTitleLabelNextLine.gradientLayer, + skeletontimeDayLabel.gradientLayer, + skeletontimeDayLabelNextLine.gradientLayer + ] + } +} diff --git a/SEDaily-IOS/KoalaTeaFlowLayout.swift b/SEDaily-IOS/KoalaTeaFlowLayout.swift new file mode 100644 index 0000000..0695f1e --- /dev/null +++ b/SEDaily-IOS/KoalaTeaFlowLayout.swift @@ -0,0 +1,217 @@ +// +// KoalaTeaFlowLayout.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 29/06/2019. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit + +public class KoalaTeaFlowLayout: UICollectionViewFlowLayout { + + fileprivate var ratio: CGFloat = 1.0 + fileprivate var topBottomMargin: CGFloat = 0 + fileprivate var leftRightMargin: CGFloat = 0 + fileprivate var cellsAcross: CGFloat = 1 + fileprivate var cellsDown: CGFloat = 1 + fileprivate var cellSpacing: CGFloat = 0 + fileprivate var collectionViewWidth: CGFloat = UIScreen.main.bounds.width + fileprivate var collectionViewHeight: CGFloat = UIScreen.main.bounds.height + fileprivate var marginOfError: CGFloat = 0 + + override public init() { + super.init() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + func setupLayout() { + let spaceBetweenCells = cellSpacing * (cellsAcross - marginOfError) + let width = (collectionViewWidth - (leftRightMargin * 2) - spaceBetweenCells) / cellsAcross - marginOfError + let height = width * ratio + let calculatedItemSize = CGSize(width: width, height: height) + + itemSize = calculatedItemSize + + sectionInset = UIEdgeInsets(top: topBottomMargin, left: leftRightMargin, bottom: topBottomMargin, right: leftRightMargin) + minimumInteritemSpacing = cellSpacing + minimumLineSpacing = cellSpacing + } + + func setupHorizontalLayout() { + let spaceBetweenCells = cellSpacing * (cellsDown - marginOfError) + let height = (collectionViewHeight - (topBottomMargin * 2) - spaceBetweenCells) / cellsDown - marginOfError + let width = height * ratio + let calculatedItemSize = CGSize(width: width, height: height) + + itemSize = calculatedItemSize + + sectionInset = UIEdgeInsets(top: topBottomMargin, left: leftRightMargin, bottom: topBottomMargin, right: leftRightMargin) + minimumInteritemSpacing = cellSpacing + minimumLineSpacing = cellSpacing + } + + func setupFullLayout() { + let height = collectionViewHeight / cellsDown + let width = collectionViewWidth / cellsAcross + let calculatedItemSize = CGSize(width: width, height: height) + + itemSize = calculatedItemSize + + sectionInset = UIEdgeInsets(top: topBottomMargin, left: leftRightMargin, bottom: topBottomMargin, right: leftRightMargin) + minimumInteritemSpacing = cellSpacing + minimumLineSpacing = cellSpacing + } + + override public func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { + return collectionView!.contentOffset + } +} + +extension KoalaTeaFlowLayout { + // Convenience Methods + + // Vertical - With Height + convenience public init(ratio: CGFloat, topBottomMargin: CGFloat, leftRightMargin: CGFloat, cellsAcross: CGFloat, cellSpacing: CGFloat, collectionViewHeight: CGFloat) { + self.init() + self.scrollDirection = .vertical + + self.ratio = ratio + self.topBottomMargin = topBottomMargin + self.leftRightMargin = leftRightMargin + self.cellsAcross = cellsAcross + self.cellSpacing = cellSpacing + self.collectionViewHeight = collectionViewHeight + + let spaceBetweenCells = cellSpacing * (cellsAcross - marginOfError) + let width = (collectionViewWidth - (leftRightMargin * 2) - spaceBetweenCells) / cellsAcross - marginOfError + let estimatedCells = (collectionViewHeight - (topBottomMargin * 2) - spaceBetweenCells) / (width * ratio) + let height = (collectionViewHeight - (topBottomMargin * 2) - spaceBetweenCells) / estimatedCells + let calculatedItemSize = CGSize(width: width, height: height) + + itemSize = calculatedItemSize + + sectionInset = UIEdgeInsets(top: topBottomMargin, left: leftRightMargin, bottom: topBottomMargin, right: leftRightMargin) + minimumInteritemSpacing = cellSpacing + minimumLineSpacing = cellSpacing + } + + // Vertical - All + convenience public init(ratio: CGFloat, topBottomMargin: CGFloat, leftRightMargin: CGFloat, cellsAcross: CGFloat, cellSpacing: CGFloat, collectionViewWidth: CGFloat) { + self.init() + self.scrollDirection = .vertical + + self.ratio = ratio + self.topBottomMargin = topBottomMargin + self.leftRightMargin = leftRightMargin + self.cellsAcross = cellsAcross + self.cellSpacing = cellSpacing + self.collectionViewWidth = collectionViewWidth + + setupLayout() + } + + // Horizontal - All + convenience public init(ratio: CGFloat, topBottomMargin: CGFloat, leftRightMargin: CGFloat, cellsDown: CGFloat, cellSpacing: CGFloat, collectionViewHeight: CGFloat) { + self.init() + self.scrollDirection = .horizontal + + self.ratio = ratio + self.topBottomMargin = topBottomMargin + self.leftRightMargin = leftRightMargin + self.cellsDown = cellsDown + self.cellSpacing = cellSpacing + self.collectionViewHeight = collectionViewHeight + + setupHorizontalLayout() + } + + // Vertical - With Margins + convenience public init(ratio: CGFloat, topBottomMargin: CGFloat, leftRightMargin: CGFloat, cellsAcross: CGFloat, cellSpacing: CGFloat) { + self.init() + self.scrollDirection = .vertical + + self.ratio = ratio + self.topBottomMargin = topBottomMargin + self.leftRightMargin = leftRightMargin + self.cellsAcross = cellsAcross + self.cellSpacing = cellSpacing + + setupLayout() + } + + // Horizontal - With Margins + convenience public init(ratio: CGFloat, topBottomMargin: CGFloat, leftRightMargin: CGFloat, cellsDown: CGFloat, cellSpacing: CGFloat) { + self.init() + self.scrollDirection = .horizontal + + self.ratio = ratio + self.topBottomMargin = topBottomMargin + self.leftRightMargin = leftRightMargin + self.cellsDown = cellsDown + self.cellSpacing = cellSpacing + + setupHorizontalLayout() + } + + // Vertical - minimum + convenience public init(ratio: CGFloat, cellsAcross: CGFloat, cellSpacing: CGFloat) { + self.init() + self.scrollDirection = .vertical + + self.ratio = ratio + self.cellsAcross = cellsAcross + self.cellSpacing = cellSpacing + + setupLayout() + } + + // Horizontal - minimum + convenience public init(ratio: CGFloat, cellsDown: CGFloat, cellSpacing: CGFloat) { + self.init() + self.ratio = ratio + self.cellsDown = cellsDown + self.cellSpacing = cellSpacing + + self.scrollDirection = .horizontal + setupHorizontalLayout() + } + + // Full + + convenience public init(cellsAcross: CGFloat, cellsDown: CGFloat, collectionViewWidth: CGFloat, collectionViewHeight: CGFloat) { + self.init() + + self.cellsAcross = cellsAcross + self.cellsDown = cellsDown + self.collectionViewWidth = collectionViewWidth + self.collectionViewHeight = collectionViewHeight + + setupFullLayout() + } + + // Vertical - With defined cell size + convenience public init(cellWidth: CGFloat, + cellHeight: CGFloat, + topBottomMargin: CGFloat, + leftRightMargin: CGFloat, + cellSpacing: CGFloat) { + self.init() + self.scrollDirection = .vertical + + self.cellSpacing = cellSpacing + + let width = cellWidth + let height = cellHeight + let calculatedItemSize = CGSize(width: width, height: height) + + itemSize = calculatedItemSize + + sectionInset = UIEdgeInsets(top: topBottomMargin, left: leftRightMargin, bottom: topBottomMargin, right: leftRightMargin) + minimumInteritemSpacing = cellSpacing + minimumLineSpacing = cellSpacing + } +} diff --git a/SEDaily-IOS/LabelCell.swift b/SEDaily-IOS/LabelCell.swift new file mode 100644 index 0000000..3ecf8a3 --- /dev/null +++ b/SEDaily-IOS/LabelCell.swift @@ -0,0 +1,72 @@ +// +// LabelCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/10/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation + +import Foundation +import Reusable +import UIKit + +class LabelCell: UITableViewCell, Reusable { + + private var label: UILabel = UILabel() + + var viewModel: ViewModel = ViewModel() { + didSet { + label.text = viewModel.text + setupLayout(style: viewModel.style) + + } + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + contentView.addSubview(label) + self.selectionStyle = .none + self.isUserInteractionEnabled = true + } + + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } +} + + +extension LabelCell { + private func setupLayout(style: ViewModel.Style) { + + label.snp.makeConstraints { (make) in + make.left.right.equalToSuperview().offset(style.marginX) + make.right.equalToSuperview().inset(style.marginX) + make.top.equalToSuperview().offset(style.marginY) + make.bottom.equalToSuperview().inset(style.marginY) + } + label.textAlignment = style.alignment + label.font = style.font + label.textColor = style.color + + self.accessoryType = style.accessory + } +} + +// MARK: - ViewModel +extension LabelCell { + struct ViewModel { + struct Style { + var marginX: CGFloat = 0 + var marginY: CGFloat = 0 + var font: UIFont = UIFont(name: "Roboto", size: 10.0)! //arbitrary + var color = UIColor.clear + var alignment = NSTextAlignment.left + var accessory: UITableViewCell.AccessoryType = .disclosureIndicator + } + var text = "" + var style = Style() + } +} diff --git a/SEDaily-IOS/LoginViewController.swift b/SEDaily-IOS/LoginViewController.swift index 8699a68..3ddf7a4 100644 --- a/SEDaily-IOS/LoginViewController.swift +++ b/SEDaily-IOS/LoginViewController.swift @@ -3,333 +3,418 @@ // SEDaily-IOS // // Created by Craig Holliday on 6/27/17. +// Modified by Dawid Cedrych on 7/9/18 // Copyright © 2017 Koala Tea. All rights reserved. // import UIKit import SwifterSwift -import SideMenu +import MBProgressHUD class LoginViewController: UIViewController { + + let scrollView = UIScrollView() + let contentView = UIView() + let topView = UIView() + let bottomView = UIView() + + let imageView = UIImageView() + let stackView = UIStackView() + + let emailTextField = InsetTextField() + let usernameTextField = InsetTextField() + let passwordTextField = InsetTextField() + let passwordConfirmTextField = InsetTextField() + let firstNameTextField = InsetTextField() + let lastNameTextField = InsetTextField() + + let submitButton = UIButton() + let toggleButton = UIButton() + + let headerLabel = UILabel() + + let networkService = API() + + override func viewDidLoad() { + super.viewDidLoad() - // let containerView = UIView() - let scrollView = UIScrollView() - let contentView = UIView() - let topView = UIView() - let bottomView = UIView() + performLayout() + self.view.backgroundColor = UIColor.white + Analytics2.loginFormViewed() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + func addBottomBorderToView(view: UIView, height: CGFloat, width: CGFloat) { + let border = CALayer() + let borderWidth = CGFloat(0.5) + border.borderColor = Stylesheet.Colors.base.cgColor + border.frame = CGRect(x: 0, y: height - borderWidth, width: width, height: height) - let imageView = UIImageView() - let stackView = UIStackView() + border.borderWidth = borderWidth + view.layer.addSublayer(border) + view.layer.masksToBounds = true + } + + private func performLayout() { + self.view.addSubview(scrollView) - let emailTextField = TextField() - let passwordTextField = TextField() - let passwordConfirmTextField = TextField() - let firstNameTextField = TextField() - let lastNameTextField = TextField() + scrollView.snp.makeConstraints { (make) -> Void in + make.edges.equalToSuperview() + } - let loginButton = UIButton() - let cancelButton = UIButton() - let signUpButton = UIButton() + self.scrollView.addSubview(contentView) + self.contentView.addSubview(stackView) - override func viewDidLoad() { - super.viewDidLoad() - - performLayout() - self.view.backgroundColor = Stylesheet.Colors.base + contentView.snp.makeConstraints { (make) -> Void in + make.top.left.right.equalToSuperview().priority(100) + make.width.equalToSuperview() + make.bottom.equalToSuperview().priority(99) + make.bottom.equalTo(stackView.snp.bottom).offset(20).priority(100) + } + + setupStackView() + setupImageview() + setupTextFields() + setupButtons() + setupHeaderLabel() + } + + private func setupHeaderLabel() { + self.headerLabel.font = UIFont.systemFont(ofSize: 32.0, weight: .bold) + self.headerLabel.text = L10n.signInHeader + self.headerLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(emailTextField.snp.left) + } + } + + private func setupTopBottomViews() { + self.view.addSubview(topView) + self.view.addSubview(bottomView) + + topView.isUserInteractionEnabled = false + bottomView.isUserInteractionEnabled = false + + topView.snp.makeConstraints { (make) -> Void in + make.top.left.right.equalToSuperview() + make.height.equalTo(view).dividedBy(2) } - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. + bottomView.snp.makeConstraints { (make) -> Void in + make.bottom.left.right.equalTo(view) + make.height.equalTo(view).dividedBy(2) } + } + + private func setupStackView() { + stackView.alignment = .center + stackView.axis = .vertical + stackView.distribution = .fillProportionally + stackView.spacing = 10 + + self.stackView.addArrangedSubview(headerLabel) + self.stackView.addArrangedSubview(emailTextField) + self.stackView.addArrangedSubview(usernameTextField) + self.stackView.addArrangedSubview(passwordTextField) + self.stackView.addArrangedSubview(passwordConfirmTextField) + self.stackView.addArrangedSubview(firstNameTextField) + self.stackView.addArrangedSubview(lastNameTextField) + self.stackView.addArrangedSubview(submitButton) + self.stackView.addArrangedSubview(toggleButton) - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + stackView.snp.makeConstraints { (make) -> Void in + make.left.right.equalToSuperview() + make.bottom.equalTo(toggleButton) } + } + + private func setupImageview() { + self.scrollView.addSubview(imageView) + imageView.contentMode = .scaleAspectFit + imageView.image = #imageLiteral(resourceName: "logo_subtitle") - func addBottomBorderToView(view: UIView, height: CGFloat, width: CGFloat) { - let border = CALayer() - let borderWidth = CGFloat(UIView.getValueScaledByScreenHeightFor(baseValue: 2)) - border.borderColor = Stylesheet.Colors.secondaryColor.cgColor - border.frame = CGRect(x: 0, y: height - borderWidth, width: width, height: height) - - border.borderWidth = borderWidth - view.layer.addSublayer(border) - view.layer.masksToBounds = true + imageView.snp.makeConstraints { (make) -> Void in + make.centerX.equalToSuperview() + make.top.equalToSuperview().inset(10) + make.bottom.equalTo(stackView.snp.top) + + let height = UIView.getValueScaledByScreenHeightFor(baseValue: 110) + make.height.equalTo(height) + make.width.equalTo(height) + } + } + + private func setupTextFields() { + let width = UIView.getValueScaledByScreenWidthFor(baseValue: 316) + let height = UIView.getValueScaledByScreenWidthFor(baseValue: 40) + emailTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func performLayout() { - self.view.addSubview(scrollView) - - scrollView.snp.makeConstraints { (make) -> Void in - make.edges.equalToSuperview() - } - - self.scrollView.addSubview(contentView) - self.contentView.addSubview(stackView) - - contentView.snp.makeConstraints { (make) -> Void in - make.top.left.right.equalToSuperview().priority(100) - make.width.equalToSuperview() - make.bottom.equalToSuperview().priority(99) - make.bottom.equalTo(stackView.snp.bottom).offset(20).priority(100) - } - - setupStackView() - setupImageview() - setupTextFields() - setupButtons() + usernameTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func setupTopBottomViews() { - self.view.addSubview(topView) - self.view.addSubview(bottomView) - - topView.isUserInteractionEnabled = false - bottomView.isUserInteractionEnabled = false - - topView.snp.makeConstraints { (make) -> Void in - make.top.left.right.equalToSuperview() - make.height.equalTo(view).dividedBy(2) - } - - bottomView.snp.makeConstraints { (make) -> Void in - make.bottom.left.right.equalTo(view) - make.height.equalTo(view).dividedBy(2) - } + + passwordTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func setupStackView() { - stackView.alignment = .center - stackView.axis = .vertical - stackView.distribution = .fillProportionally - stackView.spacing = 10 - - self.stackView.addArrangedSubview(emailTextField) - self.stackView.addArrangedSubview(passwordTextField) - self.stackView.addArrangedSubview(passwordConfirmTextField) - self.stackView.addArrangedSubview(firstNameTextField) - self.stackView.addArrangedSubview(lastNameTextField) - self.stackView.addArrangedSubview(loginButton) - self.stackView.addArrangedSubview(signUpButton) - self.stackView.addArrangedSubview(cancelButton) - - stackView.snp.makeConstraints { (make) -> Void in - make.top.equalTo(self.view.center) - make.left.right.equalToSuperview() - make.bottom.equalTo(cancelButton) - } + passwordConfirmTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func setupImageview() { - self.scrollView.addSubview(imageView) - imageView.contentMode = .scaleAspectFit - imageView.image = #imageLiteral(resourceName: "SEDaily_Logo") - - imageView.snp.makeConstraints { (make) -> Void in - make.centerX.equalToSuperview() - make.top.equalToSuperview().inset(50) - - let height = UIView.getValueScaledByScreenHeightFor(baseValue: 200) - make.height.equalTo(height) - make.width.equalTo(height) - } + firstNameTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func setupTextFields() { - let width = UIView.getValueScaledByScreenWidthFor(baseValue: 316) - let height = UIView.getValueScaledByScreenHeightFor(baseValue: 36) - emailTextField.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - passwordTextField.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - passwordConfirmTextField.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - firstNameTextField.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - lastNameTextField.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - addBottomBorderToView(view: emailTextField, height: height, width: width) - addBottomBorderToView(view: passwordTextField, height: height, width: width) - addBottomBorderToView(view: passwordConfirmTextField, height: height, width: width) - addBottomBorderToView(view: firstNameTextField, height: height, width: width) - addBottomBorderToView(view: lastNameTextField, height: height, width: width) - - emailTextField.placeholder = L10n.emailAddressPlaceholder - emailTextField.setPlaceHolderTextColor(Stylesheet.Colors.secondaryColor) - emailTextField.textColor = Stylesheet.Colors.white - emailTextField.autocorrectionType = .no - emailTextField.autocapitalizationType = .none - - passwordTextField.placeholder = L10n.passwordPlaceholder - passwordTextField.setPlaceHolderTextColor(Stylesheet.Colors.secondaryColor) - passwordTextField.textColor = Stylesheet.Colors.white - passwordTextField.autocorrectionType = .no - passwordTextField.autocapitalizationType = .none - passwordTextField.isSecureTextEntry = true - - passwordConfirmTextField.isHidden = true - passwordConfirmTextField.placeholder = L10n.confirmPasswordPlaceholder - passwordConfirmTextField.textColor = Stylesheet.Colors.white - passwordConfirmTextField.autocorrectionType = .no - passwordConfirmTextField.autocapitalizationType = .none - passwordConfirmTextField.isSecureTextEntry = true - - firstNameTextField.isHidden = true - firstNameTextField.placeholder = L10n.firstNamePlaceholder - firstNameTextField.textColor = Stylesheet.Colors.white - firstNameTextField.autocorrectionType = .no - firstNameTextField.autocapitalizationType = .none - - lastNameTextField.isHidden = true - lastNameTextField.placeholder = L10n.lastNamePlaceholder - lastNameTextField.textColor = Stylesheet.Colors.white - lastNameTextField.autocorrectionType = .no - lastNameTextField.autocapitalizationType = .none + lastNameTextField.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - private func setupButtons() { - let width = UIView.getValueScaledByScreenWidthFor(baseValue: 94) - let height = UIView.getValueScaledByScreenHeightFor(baseValue: 42) - loginButton.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - cancelButton.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - signUpButton.snp.makeConstraints { (make) -> Void in - make.width.equalTo(width) - make.height.equalTo(height) - } - - loginButton.setTitle(L10n.loginButtonTitle, for: .normal) - let cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 4) - loginButton.setTitleColor(Stylesheet.Colors.white, for: .normal) - loginButton.setBackgroundColor(color: Stylesheet.Colors.secondaryColor, forState: .normal) - loginButton.addTarget(self, action: #selector(self.loginButtonPressed), for: .touchUpInside) - loginButton.cornerRadius = cornerRadius - - cancelButton.setTitle(L10n.cancelButtonTitle, for: .normal) - cancelButton.setTitleColor(Stylesheet.Colors.white, for: .normal) - cancelButton.setBackgroundColor(color: Stylesheet.Colors.secondaryColor, forState: .normal) - cancelButton.addTarget(self, action: #selector(self.cancelButtonPressed), for: .touchUpInside) - cancelButton.cornerRadius = cornerRadius - cancelButton.isHidden = true - - signUpButton.setTitle(L10n.signUpButtonTitle, for: .normal) - signUpButton.setTitleColor(Stylesheet.Colors.white, for: .normal) - signUpButton.setBackgroundColor(color: Stylesheet.Colors.secondaryColor, forState: .normal) - signUpButton.addTarget(self, action: #selector(self.signUpButtonPressed), for: .touchUpInside) - signUpButton.cornerRadius = cornerRadius + addBottomBorderToView(view: emailTextField, height: height, width: width) + addBottomBorderToView(view: usernameTextField, height: height, width: width) + addBottomBorderToView(view: passwordTextField, height: height, width: width) + addBottomBorderToView(view: passwordConfirmTextField, height: height, width: width) + addBottomBorderToView(view: firstNameTextField, height: height, width: width) + addBottomBorderToView(view: lastNameTextField, height: height, width: width) + + emailTextField.placeholder = L10n.usernameOrEmailPlaceholder + emailTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + emailTextField.textColor = Stylesheet.Colors.blackText + emailTextField.autocorrectionType = .no + emailTextField.autocapitalizationType = .none + emailTextField.textAlignment = .left + emailTextField.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + + usernameTextField.isHidden = true + usernameTextField.placeholder = L10n.usernamePlaceholder + usernameTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + usernameTextField.textColor = Stylesheet.Colors.blackText + usernameTextField.autocorrectionType = .no + usernameTextField.autocapitalizationType = .none + usernameTextField.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + + passwordTextField.placeholder = L10n.passwordPlaceholder + passwordTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + passwordTextField.textColor = Stylesheet.Colors.blackText + passwordTextField.autocorrectionType = .no + passwordTextField.autocapitalizationType = .none + passwordTextField.isSecureTextEntry = true + passwordTextField.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + + passwordConfirmTextField.isHidden = true + passwordConfirmTextField.placeholder = L10n.confirmPasswordPlaceholder + passwordConfirmTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + passwordConfirmTextField.textColor = Stylesheet.Colors.blackText + passwordConfirmTextField.autocorrectionType = .no + passwordConfirmTextField.autocapitalizationType = .none + passwordConfirmTextField.isSecureTextEntry = true + passwordConfirmTextField.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + + firstNameTextField.isHidden = true + firstNameTextField.placeholder = L10n.firstNamePlaceholder + firstNameTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + firstNameTextField.textColor = Stylesheet.Colors.blackText + firstNameTextField.autocorrectionType = .no + firstNameTextField.autocapitalizationType = .none + + lastNameTextField.isHidden = true + lastNameTextField.placeholder = L10n.lastNamePlaceholder + lastNameTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + lastNameTextField.textColor = Stylesheet.Colors.blackText + lastNameTextField.autocorrectionType = .no + lastNameTextField.autocapitalizationType = .none + } + + private func setupButtons() { + let width = UIView.getValueScaledByScreenWidthFor(baseValue: 316) + let height = UIView.getValueScaledByScreenWidthFor(baseValue: 60) + submitButton.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - @objc func loginButtonPressed() { - guard !emailTextField.isEmpty else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailEmpty) - return - } - - guard let email = emailTextField.text else { return } - guard Helpers.isValidEmailAddress(emailAddressString: email) else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailWrongFormat) - return - } - - guard !passwordTextField.isEmpty else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordEmpty) - return - } - - guard let password = passwordTextField.text else { return } - guard password.length >= 6 else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordNotLongEnough) - return - } - - // API Login Call - API.sharedInstance.login(firstName: "", lastName: "", email: email, password: password) { (success) in - if success == false { - return - } - self.navigationController?.popViewController() - } + toggleButton.snp.makeConstraints { (make) -> Void in + make.width.equalTo(width) + make.height.equalTo(height) } - @objc func cancelButtonPressed() { - loginButton.isHidden = false - cancelButton.isHidden = true - passwordConfirmTextField.isHidden = true - firstNameTextField.isHidden = true - lastNameTextField.isHidden = true + submitButton.setTitle(L10n.loginButtonTitle, for: .normal) + submitButton.setTitleColor(Stylesheet.Colors.white, for: .normal) + submitButton.titleLabel?.font = UIFont(name: "OpenSans-SemiBold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + submitButton.setBackgroundColor(color: Stylesheet.Colors.base, forState: .normal) + submitButton.addTarget(self, action: #selector(self.loginButtonPressed), for: .touchUpInside) + + submitButton.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 6) + + + + //submitButton.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 20) + + toggleButton.setTitle(L10n.toggleToSignUpButtonTitle, for: .normal) + toggleButton.setTitleColor(Stylesheet.Colors.secondaryColor, for: .normal) + toggleButton.titleLabel?.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 14)) + toggleButton.setBackgroundColor(color: Stylesheet.Colors.clear, forState: .normal) + toggleButton.addTarget(self, action: #selector(self.toggleButtonPressed), for: .touchUpInside) + + } + + @objc func loginButtonPressed() { + guard !emailTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailEmpty) + return } - @objc func signUpButtonPressed() { - if passwordConfirmTextField.isHidden == true { - loginButton.isHidden = true - cancelButton.isHidden = false - passwordConfirmTextField.isHidden = false - firstNameTextField.isHidden = false - lastNameTextField.isHidden = false - return - } - - guard !emailTextField.isEmpty else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailEmpty) - return - } - - guard let email = emailTextField.text else { return } - guard Helpers.isValidEmailAddress(emailAddressString: email) else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailWrongFormat) - return - } - - guard !passwordTextField.isEmpty else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordEmpty) - return - } - - guard let password = passwordTextField.text else { return } - guard password.length >= 6 else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordNotLongEnough) - return - } - - guard !passwordConfirmTextField.isEmpty else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordConfirmEmpty) - return - } - - guard let passwordConfirm = passwordConfirmTextField.text else { return } - guard passwordConfirm == password else { - Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordsDonotMatch) - return - } - - // API Login Call - API.sharedInstance.register(firstName: "", lastName: "", email: email, password: password, completion: { (success) -> Void in - if success == false { - return - } - self.navigationController?.popViewController() + guard let usernameOrEmail = emailTextField.text else { return } + // @TODO: Maybe add another check here + // guard Helpers.isValidEmailAddress(emailAddressString: email) else { + // Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailWrongFormat) + // return + // } + + guard !passwordTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordEmpty) + return + } + + guard let password = passwordTextField.text else { return } + guard password.length >= 6 else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordNotLongEnough) + return + } + + ProgressIndicator.showBlockingProgress() + networkService.login(usernameOrEmail: usernameOrEmail, password: password) { (success) in + ProgressIndicator.hideBlockingProgress() + if success == false { + return + } + self.navigationController?.popViewController() + } + } + + @objc func toggleButtonPressed() { + if passwordConfirmTextField.isHidden == true { + UIView.animate(withDuration: 0.15, animations: { + Analytics2.registrationFormViewed() + self.submitButton.setTitle(L10n.signUpButtonTitle, for: .normal) + self.toggleButton.setTitle(L10n.toggleToSignInButtonTitle, for: .normal) + // Remove login target and add sign up target + self.submitButton.removeTarget(self, action: #selector(self.loginButtonPressed), for: .touchUpInside) + self.submitButton.addTarget(self, action: #selector(self.signUpButtonPressed), for: .touchUpInside) + self.headerLabel.text = L10n.signUpHeader + // Set email field to just email for signup + self.emailTextField.placeholder = L10n.emailAddressPlaceholder + self.emailTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + }, completion: { _ in + UIView.animate(withDuration: 0.15, animations: { + self.usernameTextField.alpha = 1 + self.passwordConfirmTextField.alpha = 1 + self.usernameTextField.isHidden = false + self.passwordConfirmTextField.isHidden = false + + }) + }) + } else { + Analytics2.cancelRegistrationButtonPressed() + Analytics2.loginFormViewed() + UIView.animate(withDuration: 0.15, animations: { + self.submitButton.setTitle(L10n.loginButtonTitle, for: .normal) + self.toggleButton.setTitle(L10n.toggleToSignUpButtonTitle, for: .normal) + // Remove sign up target and add login target + self.submitButton.removeTarget(self, action: #selector(self.signUpButtonPressed), for: .touchUpInside) + self.submitButton.addTarget(self, action: #selector(self.loginButtonPressed), for: .touchUpInside) + self.headerLabel.text = L10n.signInHeader + // Set email field back to "username or email" + self.emailTextField.placeholder = L10n.usernameOrEmailPlaceholder + self.emailTextField.setPlaceHolderTextColor(Stylesheet.Colors.base) + }, completion: { _ in + UIView.animate(withDuration: 0.15, animations: { + self.usernameTextField.alpha = 0 + self.passwordConfirmTextField.alpha = 0 + self.usernameTextField.isHidden = true + self.passwordConfirmTextField.isHidden = true }) + }) + } + } + + @objc func signUpButtonPressed() { + + guard !emailTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailEmpty) + return + } + + guard let email = emailTextField.text else { return } + guard Helpers.isValidEmailAddress(emailAddressString: email) else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailWrongFormat) + return } + + guard !usernameTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.usernameEmpty) + return + } + + guard let username = usernameTextField.text else { return } + // @TODO: Maybe add another check here + // guard Helpers.isValidEmailAddress(emailAddressString: email) else { + // Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.emailWrongFormat) + // return + // } + + guard !passwordTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordEmpty) + return + } + + guard let password = passwordTextField.text else { return } + guard password.length >= 6 else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordNotLongEnough) + return + } + + guard !passwordConfirmTextField.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordConfirmEmpty) + return + } + + guard let passwordConfirm = passwordConfirmTextField.text else { return } + guard passwordConfirm == password else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.passwordsDonotMatch) + return + } + + // API Login Call + networkService.register(email: email, username: username, password: password, completion: { (success) -> Void in + if success == false { + return + } + self.navigationController?.popViewController() + }) + } +} + +//not sure whether insetTextField effect is desired across the app, so I'm not making it an extension of UITextField +class InsetTextField: UITextField { + override func textRect(forBounds bounds: CGRect) -> CGRect { + return bounds.insetBy(dx: 0.0, dy: 5.0) + } + override func editingRect(forBounds bounds: CGRect) -> CGRect { + return bounds.insetBy(dx: 0.0, dy: 5.0) + } } diff --git a/SEDaily-IOS/MainFlowCoordinator.swift b/SEDaily-IOS/MainFlowCoordinator.swift new file mode 100644 index 0000000..0bcd801 --- /dev/null +++ b/SEDaily-IOS/MainFlowCoordinator.swift @@ -0,0 +1,80 @@ +// +// MainFlowCoordinator.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/26/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation + +import Foundation +import UIKit + +protocol Coordinator: AnyObject { + func configure(viewController: UIViewController) +} + +protocol MainCoordinated: AnyObject { + var mainCoordinator: MainFlowCoordinator? { get set } +} + +protocol AudioControllable: AnyObject { + var audioControlDelegate: EpisodeViewDelegate? { get set } +} + +protocol Stateful: AnyObject { + var stateController: StateController? { get set } +} + + +class MainFlowCoordinator: NSObject { + let stateController = StateController() + let rootViewController: RootViewController + + + init(mainViewController: RootViewController) { + self.rootViewController = mainViewController + super.init() + configure(viewController: mainViewController) + } + + //Here you will pass viewModel as parameter, "info" String now for the exemplary purpose + func viewController(_ viewController: UINavigationController, with viewModel: PodcastViewModel) { + if let vc = viewController.visibleViewController as? EpisodeViewController { // a mechanism to prevent pushing the same view controllers + if vc.viewModel == viewModel { + return + } + } + let vc = EpisodeViewController() + vc.viewModel = viewModel + configure(viewController: vc) + viewController.pushViewController(vc, animated: true) + } + + func viewController(_ navigationController: UINavigationController, push viewController: UIViewController) { + configure(viewController: viewController) + navigationController.pushViewController(viewController, animated: true) + } + + + +} + +extension MainFlowCoordinator: Coordinator { + func configure(viewController: UIViewController) { + (viewController as? MainCoordinated)?.mainCoordinator = self + (viewController as? Stateful)?.stateController = stateController + (viewController as? AudioControllable)?.audioControlDelegate = rootViewController.overlayController + if let rootController = viewController as? RootViewController { + rootController.childViewControllers.forEach(configure(viewController:)) + } + if let tabBarController = viewController as? UITabBarController { + tabBarController.viewControllers?.forEach(configure(viewController:)) + } + if let navigationController = viewController as? UINavigationController, + let rootViewController = navigationController.viewControllers.first { + configure(viewController: rootViewController) + } + } +} diff --git a/SEDaily-IOS/MainTabBarController.swift b/SEDaily-IOS/MainTabBarController.swift new file mode 100644 index 0000000..3db1cc2 --- /dev/null +++ b/SEDaily-IOS/MainTabBarController.swift @@ -0,0 +1,243 @@ +// +// MainTabBarController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/26/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import MessageUI +import PopupDialog +import SnapKit +import StoreKit +import SwifterSwift +import Firebase + +class MainTabBarController: UITabBarController, UITabBarControllerDelegate, MainCoordinated { + + var mainCoordinator: MainFlowCoordinator? + + let layout = UICollectionViewLayout() + + let latestVC = PodcastPageViewController() + let bookmarksVC = BookmarkCollectionViewController(collectionViewLayout: UICollectionViewLayout()) + let downloadsVC = DownloadsCollectionViewController(collectionViewLayout: UICollectionViewLayout()) + let profileVC = ProfileViewController() + + var ifset = false + + var actionSheet = UIAlertController() + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + delegate = self + NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) + self.view.backgroundColor = .white + + setupNavigationControllers(viewControllers: [latestVC, downloadsVC, bookmarksVC, profileVC]) + setupTabs() + [latestVC, downloadsVC, bookmarksVC, profileVC].forEach(setupTitleView(viewController:)) + self.tabBar.tintColor = Stylesheet.Colors.base + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + AskForReview.tryToExecute { didExecute in + if didExecute { + self.askForReview() + } + } + } + + @objc func loginObserver() { + setupNavigationControllers(viewControllers: [latestVC, downloadsVC, bookmarksVC, profileVC]) + } + + func setupNavBar(viewController: UIViewController) { + let rightBarButton = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: #selector(self.rightBarButtonPressed)) + viewController.navigationItem.rightBarButtonItem = rightBarButton + + switch UserManager.sharedInstance.getActiveUser().isLoggedIn() { + case false: + let leftBarButton = UIBarButtonItem(title: L10n.loginTitle, style: .done, target: self, action: #selector(self.loginButtonPressed)) + viewController.navigationItem.leftBarButtonItem = leftBarButton + case true: + let leftBarButton = UIBarButtonItem(title: L10n.logoutTitle, style: .plain, target: self, action: #selector(self.leftBarButtonPressed)) + + // Hacky way to show bars icon + let iconSize: CGFloat = 16.0 + let image = #imageLiteral(resourceName: "menu_hamburger") + leftBarButton.image = image + + viewController.navigationItem.leftBarButtonItem = leftBarButton + } + } + + @objc func rightBarButtonPressed() { + let layout = UICollectionViewLayout() + let searchCollectionViewController = SearchCollectionViewController(collectionViewLayout: layout) + guard let navigationController = self.selectedViewController as? UINavigationController else { return } + mainCoordinator?.viewController(navigationController, push: searchCollectionViewController) + Analytics2.searchNavButtonPressed() + } + + @objc func leftBarButtonPressed() { + self.setupLogoutSubscriptionActionSheet() + self.actionSheet.show() + } + + @objc func loginButtonPressed() { + Analytics2.loginNavButtonPressed() + let vc = LoginViewController() + guard let navigationController = self.selectedViewController as? UINavigationController else { return } + navigationController.pushViewController(vc, animated: true) + } + + @objc func logoutButtonPressed() { + Analytics2.logoutNavButtonPressed() + UserManager.sharedInstance.logoutUser() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + func setupNavigationControllers(viewControllers: [UIViewController]) { + for viewController in viewControllers { + setupNavBar(viewController: viewController) + } + } + + + func setupTabs() { + + let layout = UICollectionViewLayout() + + let latestVC1 = UINavigationController(rootViewController: latestVC) + let bookmarksVC1 = UINavigationController(rootViewController: bookmarksVC) + let downloadsVC1 = UINavigationController(rootViewController: downloadsVC) + let profileVC1 = UINavigationController(rootViewController: profileVC) + + self.viewControllers = [ + latestVC1, + bookmarksVC1, + downloadsVC1, + profileVC1 + ] + + // #if DEBUG + // // This will cause the tab bar to overflow so it will be auto turned into "More ..." + // let debugStoryboard = UIStoryboard.init(name: "Debug", bundle: nil) + // let debugViewController = debugStoryboard.instantiateViewController( + // withIdentifier: "DebugTabViewController") + // if let viewControllers = self.viewControllers { + // self.viewControllers = viewControllers + [debugViewController] + // } + // #endif + + self.tabBar.backgroundColor = .white + self.tabBar.isTranslucent = false + } + + func setupTitleView(viewController: UIViewController) { + let height = UIView.getValueScaledByScreenHeightFor(baseValue: 40) + let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: height, height: height)) + imageView.contentMode = .scaleAspectFit + imageView.image = #imageLiteral(resourceName: "Logo_BarButton") + viewController.navigationItem.titleView = imageView + } + + private func askForReview() { + let popup = PopupDialog(title: L10n.enthusiasticHello, + message: L10n.appReviewPromptQuestion, + gestureDismissal: false) + let feedbackPopup = PopupDialog(title: L10n.appReviewApology, + message: L10n.appReviewGiveFeedbackQuestion) + let feedbackYesButton = DefaultButton(title: L10n.enthusiasticSureSendEmail) { + if MFMailComposeViewController.canSendMail() { + let mail = MFMailComposeViewController() + mail.mailComposeDelegate = self + mail.setToRecipients(["jeff@softwareengineeringdaily.com"]) + mail.setSubject(L10n.appReviewEmailSubject) + + self.present(mail, animated: true, completion: nil) + } else { + let emailUnsupportedPopup = PopupDialog(title: L10n.emailUnsupportedOnDevice, message: L10n.emailUnsupportedMessage) + let okayButton = DefaultButton(title: L10n.genericOkay) { + emailUnsupportedPopup.dismiss() + } + emailUnsupportedPopup.addButton(okayButton) + self.present(emailUnsupportedPopup, animated: true, completion: nil) + } + } + + let feedbackNoButton = DefaultButton(title: L10n.noWithGratitude) { + popup.dismiss() + } + + let yesButton = DefaultButton(title: L10n.enthusiasticYes) { + SKStoreReviewController.requestReview() + AskForReview.setReviewed() + } + + let noButton = DefaultButton(title: L10n.genericNo) { + popup.dismiss() + self.present(feedbackPopup, animated: true, completion: nil) + } + + popup.addButtons([yesButton, noButton]) + feedbackPopup.addButtons([feedbackYesButton, feedbackNoButton]) + self.present(popup, animated: true, completion: nil) + } + + func setupLogoutSubscriptionActionSheet() { + self.actionSheet = UIAlertController(title: "", message: "Whatcha wanna do?", preferredStyle: .actionSheet) + self.actionSheet.popoverPresentationController?.barButtonItem = self.navigationItem.leftBarButtonItem + + switch UserManager.sharedInstance.getActiveUser().hasPremium { + case true: + self.actionSheet.addAction(title: "View Subscription", style: .default, isEnabled: true) { _ in + // Show view subscription status view + let rootVC = SubscriptionStatusViewController() + let navVC = UINavigationController(rootViewController: rootVC) + self.present(navVC, animated: true, completion: nil) + } + case false: + // self.actionSheet.addAction(title: "Purchase Subscription", style: .default, isEnabled: true) { _ in + // // Show purchase subscription view + // let rootVC = PurchaseSubscriptionViewController() + // let navVC = UINavigationController(rootViewController: rootVC) + // self.present(navVC, animated: true, completion: nil) + // } + break + default: break + } + + self.actionSheet.addAction(title: "Logout", style: .destructive, isEnabled: true) { _ in + self.logoutButtonPressed() + } + self.actionSheet.addAction(title: "Cancel", style: .cancel, isEnabled: true) { _ in + self.actionSheet.dismiss(animated: true, completion: nil) + } + } +} + +extension MainTabBarController: MFMailComposeViewControllerDelegate { + func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + switch result { + case .sent: + AskForReview.setReviewed() + default: break + } + } +} diff --git a/SEDaily-IOS/NavigationControllerExtension.swift b/SEDaily-IOS/NavigationControllerExtension.swift index 6924075..4459eb9 100644 --- a/SEDaily-IOS/NavigationControllerExtension.swift +++ b/SEDaily-IOS/NavigationControllerExtension.swift @@ -11,16 +11,16 @@ import UIKit extension UINavigationController { override open func viewDidLoad() { super.viewDidLoad() - + // Do any additional setup after loading the view. Stylesheet.applyOn(self) } - + override open func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } - + override open var preferredStatusBarStyle: UIStatusBarStyle { return Stylesheet.Contexts.Global.StatusBarStyle } diff --git a/SEDaily-IOS/NetworkService.swift b/SEDaily-IOS/NetworkService.swift new file mode 100644 index 0000000..9f368fa --- /dev/null +++ b/SEDaily-IOS/NetworkService.swift @@ -0,0 +1,20 @@ +// +// NetworkRequest.swift +// SEDaily-IOS +// +// Created by Berk Mollamustafaoglu on 13/01/2018. +// Copyright © 2018. All rights reserved. +// + +import Foundation +import Alamofire + +protocol NetworkService { + func networkRequest(_ urlString: URLConvertible, method: HTTPMethod, parameters: Parameters?, encoding: ParameterEncoding, headers: HTTPHeaders?) -> DataRequest +} + +extension NetworkService { + func networkRequest(_ urlString: URLConvertible, method: HTTPMethod, parameters: Parameters?, encoding: ParameterEncoding = URLEncoding.default, headers: HTTPHeaders?) { + return networkRequest(urlString, method: method, parameters: parameters, encoding: encoding, headers: headers) + } +} diff --git a/SEDaily-IOS/NotificationTableViewCell.swift b/SEDaily-IOS/NotificationTableViewCell.swift new file mode 100644 index 0000000..82c726d --- /dev/null +++ b/SEDaily-IOS/NotificationTableViewCell.swift @@ -0,0 +1,66 @@ +// +// NotificationTableViewCell.swift +// SEDaily-IOS +// +// Created by Keith Holliday on 4/2/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable +import SnapKit +import SwifterSwift +import KTResponsiveUI +import Kingfisher + +class NotificationTableViewCell: UITableViewCell, Reusable { + public var cellLabel: UILabel! + public var cellToggle: UISwitch! + var separator: UIView! + + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + cellLabel = UILabel() + cellLabel.textColor = .black + cellLabel.baselineAdjustment = .alignCenters + cellLabel.numberOfLines = 0 + cellLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + cellLabel.textColor = Stylesheet.Colors.dark + cellToggle = UISwitch() + cellToggle.tintColor = Stylesheet.Colors.light + cellToggle.onTintColor = Stylesheet.Colors.base + + + self.contentView.addSubview(cellLabel) + self.contentView.addSubview(cellToggle) + setupSeparator() + + cellLabel.snp.makeConstraints { (make) in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + } + + cellToggle.snp.makeConstraints { (make) in + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerY.equalToSuperview() + } + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(2.0) + } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + func setupSeparator() { + separator = UIView() + contentView.addSubview(separator) + separator.backgroundColor = Stylesheet.Colors.light + } +} diff --git a/SEDaily-IOS/Notifications.swift b/SEDaily-IOS/Notifications.swift index f3b442c..e964bea 100644 --- a/SEDaily-IOS/Notifications.swift +++ b/SEDaily-IOS/Notifications.swift @@ -9,5 +9,8 @@ import UIKit extension Notification.Name { - static let loginChanged = Notification.Name("loginChanged") + static let loginChanged = Notification.Name("loginChanged") + static let viewModelUpdated = Notification.Name("viewModelUpdated") + static let episodeViewWillExpand = Notification.Name("episodeViewWillExpand") + static let reloadEpisodeView = Notification.Name("reloadEpisodeView") } diff --git a/SEDaily-IOS/NotificationsController.swift b/SEDaily-IOS/NotificationsController.swift new file mode 100644 index 0000000..6a1e36e --- /dev/null +++ b/SEDaily-IOS/NotificationsController.swift @@ -0,0 +1,105 @@ +// +// NotificationsController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/9/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import UIKit +import UserNotifications + + +class NotificationsController { + + let center = UNUserNotificationCenter.current() + let options: UNAuthorizationOptions = [.alert, .sound] + + var notificationsSubscribed = false + + let notificationIndentifier = "sedailyNotification" + let notificationPrefKey = "sedaily-notificationsSubscribed" + + required init() { + let defaults = UserDefaults.standard + let subscribed = defaults.object(forKey: notificationPrefKey) as? Bool + if subscribed != nil { + notificationsSubscribed = subscribed! + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + + + + + func assignNotifications () { + center.getNotificationSettings { (settings) in + if settings.authorizationStatus != .authorized { + // Notifications not allowed + self.requestNotifications() + } else { + self.createNotification() + } + } + } + + func requestNotifications() { + center.requestAuthorization(options: options, completionHandler: { + (granted, error) in + if !granted { + return + } + + self.createNotification() + }) + } + + func createNotification() { + let date = self.createDate(weekday: 2, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + + let date2 = self.createDate(weekday: 4, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + + let date3 = self.createDate(weekday: 6, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + } + + func createDate(weekday: Int, hour: Int, minute: Int) -> Date{ + var components = DateComponents() + components.hour = hour + components.minute = minute + components.weekday = weekday // sunday = 1 ... saturday = 7 + components.weekdayOrdinal = 10 + components.timeZone = .current + + let calendar = Calendar(identifier: .gregorian) + return calendar.date(from: components)! + } + + //Schedule Notification with weekly bases. + func scheduleNotification(at date: Date, body: String, titles: String) { + let triggerWeekly = Calendar.current.dateComponents([.weekday, .hour, .minute, .second], from: date) + + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerWeekly, repeats: true) + + let content = UNMutableNotificationContent() + content.title = titles + content.body = body + content.sound = UNNotificationSound.default() + content.categoryIdentifier = "podcast" + + let request = UNNotificationRequest(identifier: notificationIndentifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) {(error) in + if let error = error { + print("Uh oh! We had an error: \(error)") + } + } + } +} diff --git a/SEDaily-IOS/NotificationsTableViewController.swift b/SEDaily-IOS/NotificationsTableViewController.swift new file mode 100644 index 0000000..976c78e --- /dev/null +++ b/SEDaily-IOS/NotificationsTableViewController.swift @@ -0,0 +1,159 @@ +// +// NotificationTableViewController.swift +// SEDaily-IOS +// +// Created by Keith Holliday on 4/1/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import UserNotifications + +private let reuseIdentifier = "notifs-reuseIdentifier" + +class NotificationsTableViewController: UITableViewController { + let center = UNUserNotificationCenter.current() + let options: UNAuthorizationOptions = [.alert, .sound] + var notificationsSubscribed = false + + let notificationIndentifier = "sedailyNotification" + let notificationPrefKey = "sedaily-notificationsSubscribed" + + required init() { + super.init(style: .plain) + self.tabBarItem = UITabBarItem(title: L10n.tabBarNotifications, image: #imageLiteral(resourceName: "mic_stand"), selectedImage: #imageLiteral(resourceName: "mic_stand_selected")) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let defaults = UserDefaults.standard + let subscribed = defaults.object(forKey: notificationPrefKey) as? Bool + if subscribed != nil { + notificationsSubscribed = subscribed! + } + + self.tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: reuseIdentifier) + + self.tableView.rowHeight = UIView.getValueScaledByScreenHeightFor(baseValue: 85) + Analytics2.notificationPageViewed() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + guard let cell = tableView.dequeueReusableCell( + withIdentifier: reuseIdentifier, + for: indexPath) as? NotificationTableViewCell else { + return UITableViewCell() + } + + // Configure the cell... + cell.cellLabel.text = "Enable Daily Notifications" + cell.cellToggle.addTarget(self, action: #selector(switchValueDidChange), for: .touchUpInside) + + if notificationsSubscribed { + cell.cellToggle.setOn(true, animated: true) + } + + return cell + } + + @objc func switchValueDidChange(sender: UISwitch!) { + if sender.isOn { + assignNotifications() + notificationsSubscribed = true + } else { + // cancel + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + notificationsSubscribed = false + } + + let defaults = UserDefaults.standard + defaults.set(notificationsSubscribed, forKey: notificationPrefKey) + } + + func assignNotifications () { + center.getNotificationSettings { (settings) in + if settings.authorizationStatus != .authorized { + // Notifications not allowed + self.requestNotifications() + } else { + self.createNotification() + } + } + } + + func requestNotifications() { + center.requestAuthorization(options: options, completionHandler: { + (granted, error) in + if !granted { + print("Something went wrong") + return + } + + self.createNotification() + }) + } + + func createNotification() { + let date = self.createDate(weekday: 2, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + + let date2 = self.createDate(weekday: 4, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + + let date3 = self.createDate(weekday: 6, hour: 10, minute: 0) + self.scheduleNotification(at: date, body: L10n.mwfNotificationBody, titles: L10n.mwfNotificationTitle) + } + + func createDate(weekday: Int, hour: Int, minute: Int) -> Date{ + var components = DateComponents() + components.hour = hour + components.minute = minute + components.weekday = weekday // sunday = 1 ... saturday = 7 + components.weekdayOrdinal = 10 + components.timeZone = .current + + let calendar = Calendar(identifier: .gregorian) + return calendar.date(from: components)! + } + + //Schedule Notification with weekly bases. + func scheduleNotification(at date: Date, body: String, titles: String) { + let triggerWeekly = Calendar.current.dateComponents([.weekday, .hour, .minute, .second], from: date) + + let trigger = UNCalendarNotificationTrigger(dateMatching: triggerWeekly, repeats: true) + + let content = UNMutableNotificationContent() + content.title = titles + content.body = body + content.sound = UNNotificationSound.default() + content.categoryIdentifier = "podcast" + + let request = UNNotificationRequest(identifier: notificationIndentifier, content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) {(error) in + if let error = error { + print("Uh oh! We had an error: \(error)") + } + } + } +} diff --git a/SEDaily-IOS/OfflineDownloadsManager.swift b/SEDaily-IOS/OfflineDownloadsManager.swift new file mode 100644 index 0000000..9a4d1ff --- /dev/null +++ b/SEDaily-IOS/OfflineDownloadsManager.swift @@ -0,0 +1,166 @@ +// +// OfflineDownloadsManager.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 11/21/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import Disk +import Alamofire + +public enum OfflineDownloadsError: Error { +} + +public protocol OfflineDownloadsProtocol { + typealias ProgressCallback = (Double) -> Void + typealias RepositorySuccessCallback = () -> Void + typealias RepositoryErrorCallback = (Error?) -> Void + + func save(podcast: PodcastViewModel, onProgress: @escaping ProgressCallback, onSuccess: @escaping RepositorySuccessCallback, onFailure: @escaping RepositoryErrorCallback) + func deletePodcast(podcast: PodcastViewModel, completion: @escaping () -> Void) + static func findURL(for podcast: PodcastViewModel) -> URL? +} + +public class OfflineDownloadsManager: NSObject, OfflineDownloadsProtocol { + static let sharedInstance = OfflineDownloadsManager() + + private lazy var backgroundManager: Alamofire.SessionManager = { + let bundleIdentifier = "com.sed" + return Alamofire.SessionManager(configuration: URLSessionConfiguration.background(withIdentifier: bundleIdentifier + ".background")) + }() + + private var downloadRequests: [String: DownloadRequest] = [:] + + public func save(podcast: PodcastViewModel, + onProgress: @escaping OfflineDownloadsProtocol.ProgressCallback, + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping OfflineDownloadsProtocol.RepositoryErrorCallback) { + let utilityQueue = DispatchQueue.global(qos: .utility) + + guard let urlString = podcast.mp3URL?.absoluteString else { + onFailure(nil) + return + } + let fileName = podcast.getFilename() + + if let existingRequest = self.existingDownloadRequest(with: fileName) { + existingRequest.downloadProgress(closure: { (progress) in + DispatchQueue.main.async { + onProgress(progress.fractionCompleted) + } + }).responseData(completionHandler: { (response) in + self.downloadRequests.removeValue(forKey: fileName) + + switch response.result { + case .success: + DispatchQueue.main.async { + onSuccess() + } + case .failure(let error): + DispatchQueue.main.async { + print(error.localizedDescription) + if error.localizedDescription == "cancelled" { + onFailure(nil) + return + } + onFailure(error) + } + } + }) + + return + } + + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + assertionFailure("No documents url found") + onFailure(nil) + return + } + + let destination: DownloadRequest.DownloadFileDestination? = { _, _ in + let fileURL = documentsURL.appendingPathComponent(fileName).appendingPathExtension("mp3") + + if FileManager.default.fileExists(atPath: fileURL.path) { + do { + try FileManager.default.removeItem(atPath: fileURL.path) + } catch { + print("Error removing item at path: %@", fileURL.path) + } + } + + return (fileURL, [.removePreviousFile, .createIntermediateDirectories]) + } + + let request = backgroundManager.download(urlString, to: destination) + .downloadProgress(queue: utilityQueue) { progress in + DispatchQueue.main.async { + onProgress(progress.fractionCompleted) + } + } + .responseData { response in + self.downloadRequests.removeValue(forKey: fileName) + + switch response.result { + case .success: + DispatchQueue.main.async { + onSuccess() + } + case .failure(let error): + DispatchQueue.main.async { + print(error.localizedDescription) + if error.localizedDescription == "cancelled" { + onFailure(nil) + return + } + onFailure(error) + } + } + } + + self.downloadRequests[fileName] = request + } + + public func deletePodcast(podcast: PodcastViewModel, + completion: @escaping () -> Void) { + // Cancel download request if one is active + if self.downloadRequests.has(key: podcast.getFilename()) { + self.downloadRequests[podcast.getFilename()]?.cancel() + } + + guard let fileStringToDelete = podcast.downloadedFileURLString else { + completion() + return + } + do { + try FileManager.default.removeItem(atPath: fileStringToDelete) + completion() + } catch { + print("Could not clear file because of error: \(error.localizedDescription)") + completion() + } + } + + public static func findURL(for podcast: PodcastViewModel) -> URL? { + guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + assertionFailure("No documents url found") + return nil + } + do { + let fileURLs = try FileManager.default.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) + let files = fileURLs.filter { $0.pathExtension == "mp3" } + + let urls = files.filter { $0.lastPathComponent.deletingPathExtension == podcast.getFilename() } + + return urls.first + } catch let error { + print(error.localizedDescription) + return nil + } + } + + private func existingDownloadRequest(with name: String) -> DownloadRequest? { + return downloadRequests[name] + } +} diff --git a/SEDaily-IOS/OpenSans-Light.ttf b/SEDaily-IOS/OpenSans-Light.ttf new file mode 100755 index 0000000..0d38189 Binary files /dev/null and b/SEDaily-IOS/OpenSans-Light.ttf differ diff --git a/SEDaily-IOS/OpenSans-Regular.ttf b/SEDaily-IOS/OpenSans-Regular.ttf new file mode 100755 index 0000000..db43334 Binary files /dev/null and b/SEDaily-IOS/OpenSans-Regular.ttf differ diff --git a/SEDaily-IOS/OpenSans-Semibold.ttf b/SEDaily-IOS/OpenSans-Semibold.ttf new file mode 100755 index 0000000..1a7679e Binary files /dev/null and b/SEDaily-IOS/OpenSans-Semibold.ttf differ diff --git a/SEDaily-IOS/OverlayViewController.swift b/SEDaily-IOS/OverlayViewController.swift new file mode 100644 index 0000000..eb4c98c --- /dev/null +++ b/SEDaily-IOS/OverlayViewController.swift @@ -0,0 +1,289 @@ +// +// OverlayViewController.swift +// ExpandableOverlay +// +// Created by Dawid Cedrych on 6/18/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import UIKit +import AVFoundation +import SnapKit + +protocol OverlayControllerDelegate: class { + func didSelectInfo(viewModel: PodcastViewModel) + func didTapCollapse() + func didTapStop() + func didTapPlay() +} + +class OverlayViewController: UIViewController, Stateful { + var stateController: StateController? + + let networkService = API() + + private static var userSettingPlaybackSpeedKey = "PlaybackSpeed" + + /// The instance of `AssetPlaybackManager` that the app uses for managing playback. + private var assetPlaybackManager: AssetPlayer! = nil + + /// The instance of `RemoteCommandManager` that the app uses for managing remote command events. + private var remoteCommandManager: RemoteCommandManager! = nil + + /// The instance of PlayProgressModelController to retrieve and save progress of the playback + private var progressController = PlayProgressModelController() + + weak var delegate: OverlayControllerDelegate? + + var viewModel: PodcastViewModel = PodcastViewModel() { + didSet { + audioPlayerView?.viewModel = viewModel + //audioPlayerView?.performLayout() + } + } + + + var expanded: Bool = false { + didSet { + audioPlayerView?.expanded = expanded + } + } + + var audioPlayerView: AudioPlayerView? + + override func viewDidLoad() { + super.viewDidLoad() + audioPlayerView = AudioPlayerView(frame: CGRect.zero, audioViewDelegate: self) + view.addSubview(audioPlayerView!) + audioPlayerView?.snp.remakeConstraints { make in + make.edges.equalToSuperview() + } + } + + fileprivate func setText(text: String?) { + audioPlayerView?.setText(text: text) + } + + private func saveProgress() { + + guard viewModel != nil else { return } + progressController.retrieve() + } + + private func loadAudio(podcastViewModel: PodcastViewModel) { + var fileURL: URL? = nil + fileURL = podcastViewModel.mp3URL + if let urlString = podcastViewModel.downloadedFileURLString { + fileURL = URL(fileURLWithPath: urlString) + } + guard let url = fileURL else { return } + self.setupAudioManager( + url: url, + podcastViewModel: podcastViewModel) + } + + fileprivate func setupAudioManager(url: URL, podcastViewModel: PodcastViewModel) { + var savedTime: Float = 0 + + //Load Saved time + + + if progressController.episodesPlayProgress[podcastViewModel._id] != nil { + savedTime = progressController.episodesPlayProgress[podcastViewModel._id]!.currentTime + } else { + progressController.episodesPlayProgress[podcastViewModel._id]?.currentTime = 0 + } + + log.info(savedTime, "savedtime") + + let asset = Asset(assetName: podcastViewModel.podcastTitle, url: url, savedTime: savedTime) + assetPlaybackManager = AssetPlayer(asset: asset) + assetPlaybackManager.playerDelegate = self + + // If you want remote commands + // Initializer the `RemoteCommandManager`. + self.remoteCommandManager = RemoteCommandManager(assetPlaybackManager: assetPlaybackManager) + + // Always enable playback commands in MPRemoteCommandCenter. + self.remoteCommandManager.activatePlaybackCommands(true) + self.remoteCommandManager.toggleChangePlaybackPositionCommand(true) + self.remoteCommandManager.toggleSkipBackwardCommand(true, interval: 30) + self.remoteCommandManager.toggleSkipForwardCommand(true, interval: 30) + self.remoteCommandManager.toggleChangePlaybackPositionCommand(true) + } + + fileprivate func handleStateChange(for state: AssetPlayerPlaybackState) { + self.setText(text: viewModel.podcastTitle) + + switch state { + case .setup: + //audioPlayerView?.isFirstLoad = true + audioPlayerView?.disableButtons() + audioPlayerView?.startActivityAnimating() + + audioPlayerView?.playButton.isHidden = true + audioPlayerView?.pauseButton.isHidden = true + case .playing: + audioPlayerView?.enableButtons() + audioPlayerView?.stopActivityAnimating() + + audioPlayerView?.playButton.isHidden = true + audioPlayerView?.pauseButton.isHidden = false + case .paused: + audioPlayerView?.enableButtons() + audioPlayerView?.stopActivityAnimating() + + audioPlayerView?.playButton.isHidden = false + audioPlayerView?.pauseButton.isHidden = true + case .interrupted: + //@TODO: handle interrupted + break + case .failed: + break + //self.audioOverlayDelegate?.animateOverlayOut() + case .buffering: + audioPlayerView?.startActivityAnimating() + audioPlayerView?.playButton.isHidden = true + audioPlayerView?.pauseButton.isHidden = true + //audioPlayerView?.pauseButton.isHidden = true + case .stopped: + // dismiss whole overlay + stateController?.setCurrentlyPlaying(id: "") + let userInfo = ["viewModel": viewModel] + NotificationCenter.default.post(name: .reloadEpisodeView, object: nil, userInfo: userInfo) + } + } + +} + +extension OverlayViewController: AssetPlayerDelegate { + func currentAssetDidChange(_ player: AssetPlayer) { + log.debug("asset did change") + if let playbackSpeedValue = UserDefaults.standard.object(forKey: OverlayViewController.userSettingPlaybackSpeedKey) as? Float, + let playbackSpeed = PlaybackSpeed(rawValue: playbackSpeedValue) { + audioPlayerView?.currentSpeed = playbackSpeed + audioRateChanged(newRate: playbackSpeedValue) + } else { + audioPlayerView?.currentSpeed = ._1x + } + } + + func playerIsSetup(_ player: AssetPlayer) { + audioPlayerView?.updateSlider(maxValue: player.maxSecondValue) + } + + func playerPlaybackStateDidChange(_ player: AssetPlayer) { + guard let state = player.state else { return } + self.handleStateChange(for: state) + } + + func playerCurrentTimeDidChange(_ player: AssetPlayer) { + + // Update progress + let podcastViewModel = self.viewModel + let progress = PlayProgress(id: podcastViewModel._id, currentTime: Float(player.currentTime), totalLength: Float(player.maxSecondValue)) + progressController.episodesPlayProgress[podcastViewModel._id] = progress + + if round(player.currentTime).truncatingRemainder(dividingBy: 5.0) == 0.0 { + progressController.save() + } + + audioPlayerView?.updateTimeLabels(currentTimeText: player.timeElapsedText, timeLeftText: player.timeLeftText) + + audioPlayerView?.updateSlider(currentValue: Float(player.currentTime)) + } + + func playerPlaybackDidEnd(_ player: AssetPlayer) { + // Reset progress + progressController.episodesPlayProgress[viewModel._id]?.currentTime = 0.0 + progressController.save() + } + + func playerIsLikelyToKeepUp(_ player: AssetPlayer) { + //@TODO: Nothing to do here? + } + + func playerBufferTimeDidChange(_ player: AssetPlayer) { + audioPlayerView?.updateBufferSlider(bufferValue: player.bufferedTime) + } + +} + + +extension OverlayViewController: AudioPlayerViewDelegate { + + + func detailsButtonPressed() { + delegate?.didSelectInfo(viewModel: viewModel) + } + + func playButtonPressed() { + if stateController?.isFirstLoad ?? true { + playAudio(podcastViewModel: viewModel) + stateController?.isFirstLoad = false + } else { + assetPlaybackManager?.play() } + } + + func pauseButtonPressed() { + assetPlaybackManager?.pause() + } + + func skipForwardButtonPressed() { + assetPlaybackManager?.skipForward(30) + } + + func skipBackwardButtonPressed() { + assetPlaybackManager?.skipBackward(30) + } + + func collapseButtonPressed() { + delegate?.didTapCollapse() + } + + func audioRateChanged(newRate: Float) { + assetPlaybackManager?.changePlayerPlaybackRate(to: newRate) + UserDefaults.standard.set(newRate, forKey: OverlayViewController.userSettingPlaybackSpeedKey) + } + + func playbackSliderValueChanged(value: Float) { + let cmTime = CMTimeMake(Int64(value), 1) + assetPlaybackManager?.seekTo(cmTime) + } + + +} + +extension OverlayViewController: EpisodeViewDelegate { + + + func playAudio(podcastViewModel: PodcastViewModel) { + guard let state = stateController else { return } + if !state.isOverlayShowing { + delegate?.didTapPlay() + } + viewModel = podcastViewModel + // TODO: for search bug fix - deleted guest image on next launch +// let userInfo = ["viewModel": podcastViewModel] +// NotificationCenter.default.post(name: .viewModelUpdated, object: nil, userInfo: userInfo) +// print(podcastViewModel.guestImageURL) + // + Tracker.logPlayPodcast(podcast: podcastViewModel) + self.setText(text: podcastViewModel.podcastTitle) + self.saveProgress() + self.loadAudio(podcastViewModel: podcastViewModel) + stateController?.setCurrentlyPlaying(id: podcastViewModel._id) + // TODO: only mark if logged in + networkService.markAsListened(postId: podcastViewModel._id) + Analytics2.podcastPlayed(podcastId: podcastViewModel._id) + PlayProgressModelController.saveRecentlyListenedEpisodeId(id: podcastViewModel._id) + } + + + func stopAudio() { + assetPlaybackManager?.stop() + delegate?.didTapStop() + } + + +} diff --git a/SEDaily-IOS/PlayProgress.swift b/SEDaily-IOS/PlayProgress.swift new file mode 100644 index 0000000..26f6679 --- /dev/null +++ b/SEDaily-IOS/PlayProgress.swift @@ -0,0 +1,31 @@ +// +// PlayProgress.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/11/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation + +struct PlayProgress: Codable { + let id: String + var currentTime: Float + var totalLength: Float + var progressFraction: Float { + get { + return currentTime / totalLength + } + } + var timeLeft: Float { + get { + return totalLength - currentTime + } + } + + init(id: String, currentTime: Float, totalLength: Float) { + self.id = id + self.currentTime = currentTime + self.totalLength = totalLength + } +} diff --git a/SEDaily-IOS/PlayProgressModelController.swift b/SEDaily-IOS/PlayProgressModelController.swift new file mode 100644 index 0000000..dc3e4b0 --- /dev/null +++ b/SEDaily-IOS/PlayProgressModelController.swift @@ -0,0 +1,78 @@ +// +// PlayProgressModelController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/12/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation + +typealias PlayProgressDict = [String: PlayProgress] + +class PlayProgressModelController { + + var episodesPlayProgress: PlayProgressDict = PlayProgressDict() + + static func saveRecentlyListenedEpisodeId(id: String) { + let defaults = UserDefaults.standard + defaults.set(id, forKey: "sedaily-recentlyListened") + } + + static func cleanRecentlyListenedEpisodeId() { + let defaults = UserDefaults.standard + defaults.set("", forKey: "sedaily-recentlyListened") + } + + static func getRecentlyListenedEpisodeId() -> String? { + let defaults = UserDefaults.standard + guard let id = defaults.object(forKey: "sedaily-recentlyListened") as? String else { return nil} + return id + } + + func save() { + var progressToSave: [String: Data] = [String: Data]() + for (key, playProgress) in self.episodesPlayProgress { + guard let playProgressData = encodePlayProgress(from: playProgress) else { return } + progressToSave[key] = playProgressData + } + saveToDefaults(progressToSave: progressToSave) + } + + func retrieve() { + guard let fetched = fetchFromDefaults() else { return } + var result: PlayProgressDict = PlayProgressDict() + for (key, playProgressData) in fetched { + guard let playProgress = decodePlayProgress(from: playProgressData) else { return } + result[key] = playProgress + } + episodesPlayProgress = result + } + +} + +private extension PlayProgressModelController { + + private func saveToDefaults(progressToSave: [String: Data]) { + let defaults = UserDefaults.standard + defaults.set(progressToSave, forKey: "sedaily-playProgress") + } + + private func fetchFromDefaults()-> [String: Data]? { + let defaults = UserDefaults.standard + guard let fetchedData = defaults.object(forKey: "sedaily-playProgress") as? [String: Data] else { return nil} + return fetchedData + } + + private func decodePlayProgress(from data: Data)-> PlayProgress? { + guard let fetchedPlayProgress = try? PropertyListDecoder().decode(PlayProgress.self, from: data) + else { return nil } + return fetchedPlayProgress + } + + private func encodePlayProgress(from playProgress: PlayProgress)-> Data? { + guard let progressData = try? PropertyListEncoder().encode(playProgress) else { return nil } + return progressData + } +} + diff --git a/SEDaily-IOS/PlaybackSpeed.swift b/SEDaily-IOS/PlaybackSpeed.swift new file mode 100644 index 0000000..21ce912 --- /dev/null +++ b/SEDaily-IOS/PlaybackSpeed.swift @@ -0,0 +1,62 @@ +// +// PlaybackSpeed.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 16/07/2019. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation + +enum PlaybackSpeed: Float { + case _1x = 1.0 + case _1_2x = 1.2 + case _1_4x = 1.4 + case _1_6x = 1.6 + case _1_8x = 1.8 + case _2x = 2.0 + case _2_5x = 2.5 + case _3x = 3.0 + + var title: String { + switch self { + case ._1x: + return "1x (Normal Speed)" + case ._1_2x: + return "1.2x" + case ._1_4x: + return "1.4x" + case ._1_6x: + return "1.6x" + case ._1_8x: + return "1.8x" + case ._2x: + return "⏩ 2x ⏩" + case ._2_5x: + return "2.5x" + case ._3x: + return "🔥 3x 🔥" + } + } + + var shortTitle: String { + switch self { + case ._1x: + return "1x" + case ._1_2x: + return "1.2x" + case ._1_4x: + return "1.4x" + case ._1_6x: + return "1.6x" + case ._1_8x: + return "1.8x" + case ._2x: + return "2x" + case ._2_5x: + return "2.5x" + case ._3x: + return "3x" + } + } +} diff --git a/SEDaily-IOS/PlayerView.swift b/SEDaily-IOS/PlayerView.swift new file mode 100644 index 0000000..26185b9 --- /dev/null +++ b/SEDaily-IOS/PlayerView.swift @@ -0,0 +1,35 @@ +// +// PlayerView.swift +// KoalaTeaPlayer +// +// Created by Craig Holliday on 8/4/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// +import UIKit +import AVFoundation + +import UIKit +import AVFoundation + +/// A simple `UIView` subclass that is backed by an `AVPlayerLayer` layer. +public class PlayerView: UIView { + public var player: AVPlayer? { + get { + return playerLayer.player + } + + set { + playerLayer.player = newValue + } + } + + var playerLayer: AVPlayerLayer { + // swiftlint:disable force_cast + return layer as! AVPlayerLayer + // swiftlint:enable force_cast + } + + override public class var layerClass: AnyClass { + return AVPlayerLayer.self + } +} diff --git a/SEDaily-IOS/Podcast.swift b/SEDaily-IOS/Podcast.swift index 385af5c..3f74c71 100644 --- a/SEDaily-IOS/Podcast.swift +++ b/SEDaily-IOS/Podcast.swift @@ -9,113 +9,154 @@ import Foundation enum PodcastTypes: String { - case new = "new" - case top = "top" - case recommended = "recommended" + case new + case top + case recommended } enum PodcastCategoryIds: Int { - case All = -1 - case Business_and_Philosophy = 1068 - case Blockchain = 1082 - case Cloud_Engineering = 1079 - case Data = 1081 - case JavaScript = 1084 - case Machine_Learning = 1080 - case Open_Source = 1078 - case Security = 1083 - case Hackers = 1085 - case Greatest_Hits = 1069 - - var description: String { - switch self { - case .All: - return L10n.tabTitleAll - case .Business_and_Philosophy: - return L10n.tabTitleBusinessAndPhilosophy - case .Blockchain: - return L10n.tabTitleBlockchain - case .Cloud_Engineering: - return L10n.tabTitleCloudEngineering - case .Data: - return L10n.tabTitleData - case .JavaScript: - return L10n.tabTitleJavaScript - case .Machine_Learning: - return L10n.tabTitleMachineLearning - case .Open_Source: - return L10n.tabTitleOpenSource - case .Security: - return L10n.tabTitleSecurity - case .Hackers: - return L10n.tabTitleHackers - case .Greatest_Hits: - return L10n.tabTitleGreatestHits - } - } + case All = -1 + case Business_and_Philosophy = 1068 + case Blockchain = 1082 + case Cloud_Engineering = 1079 + case Data = 1081 + case JavaScript = 1084 + case Machine_Learning = 1080 + case Open_Source = 1078 + case Security = 1083 + case Hackers = 1085 + case Greatest_Hits = 1069 + + var description: String { + switch self { + case .All: + return L10n.tabTitleAll + case .Business_and_Philosophy: + return L10n.tabTitleBusinessAndPhilosophy + case .Blockchain: + return L10n.tabTitleBlockchain + case .Cloud_Engineering: + return L10n.tabTitleCloudEngineering + case .Data: + return L10n.tabTitleData + case .JavaScript: + return L10n.tabTitleJavaScript + case .Machine_Learning: + return L10n.tabTitleMachineLearning + case .Open_Source: + return L10n.tabTitleOpenSource + case .Security: + return L10n.tabTitleSecurity + case .Hackers: + return L10n.tabTitleHackers + case .Greatest_Hits: + return L10n.tabTitleGreatestHits + } + } } public struct Podcast: Codable { - let _id: String - let date: String - let link: String - let categories: [Int]? - let tags: [Int]? - let mp3: String - let featuredImage: String? - struct Content: Codable { - let rendered: String - } - let content: Content - struct Title: Codable { - let rendered: String - } - let title: Title - let score: Int - var type: String? = "new" - var upvoted: Bool? - var downvoted: Bool? + let _id: String + let thread: ForumThreadLite? + let date: String + let link: String + let categories: [Int]? + let tags: [Int]? + let mp3: String + let featuredImage: String? + let guestImage: String? + struct Content: Codable { + let rendered: String + } + let content: Content + struct Title: Codable { + let rendered: String + } + let title: Title + let score: Int? + var type: String? = "new" + var upvoted: Bool? + var downvoted: Bool? + var bookmarked: Bool? + var downloaded: Bool? + var transcriptURL: String? +} + +extension Podcast { + init(viewModel: PodcastViewModel) { + self._id = viewModel._id + self.date = viewModel.uploadDateiso8601 + var link = "" + if let postLinkString = viewModel.postLinkURL?.absoluteString { + link = postLinkString + } + self.link = link + self.categories = viewModel.categories + self.tags = viewModel.tags + var mp3 = "" + if let mp3UrlString = viewModel.mp3URL?.absoluteString { + mp3 = mp3UrlString + } + self.mp3 = mp3 + var featuredImage = "" + var guestImage = "" + if let featuredImageUrlString = viewModel.featuredImageURL?.absoluteString { + featuredImage = featuredImageUrlString + } + self.featuredImage = featuredImage + self.guestImage = guestImage + self.content = Content(rendered: viewModel.encodedPodcastDescription) + self.title = Title(rendered: viewModel.encodedPodcastTitle) + self.score = viewModel.score + self.thread = viewModel.thread + self.upvoted = viewModel.isUpvoted + self.downvoted = viewModel.isDownvoted + self.bookmarked = viewModel.isBookmarked + self.transcriptURL = "" + self.downloaded = viewModel.isDownloaded + } } extension Podcast: Equatable { - public static func ==(lhs: Podcast, rhs: Podcast) -> Bool { - return lhs._id == rhs._id && - lhs.date == rhs.date && - lhs.link == rhs.link && - lhs.categories ?? [] == rhs.categories ?? [] && - lhs.tags ?? [] == rhs.tags ?? [] && - lhs.mp3 == rhs.mp3 && - lhs.featuredImage == rhs.featuredImage && - lhs.content.rendered == rhs.content.rendered && - lhs.title.rendered == rhs.title.rendered && - lhs.score == rhs.score && - lhs.type == rhs.type - } + public static func == (lhs: Podcast, rhs: Podcast) -> Bool { + return lhs._id == rhs._id && + lhs.date == rhs.date && + lhs.link == rhs.link && + lhs.categories ?? [] == rhs.categories ?? [] && + lhs.tags ?? [] == rhs.tags ?? [] && + lhs.mp3 == rhs.mp3 && + lhs.featuredImage == rhs.featuredImage && + lhs.guestImage == rhs.guestImage && + lhs.content.rendered == rhs.content.rendered && + lhs.title.rendered == rhs.title.rendered && + lhs.score == rhs.score && + lhs.transcriptURL == rhs.transcriptURL && + lhs.type == rhs.type + } } extension Podcast { - func getLastUpdatedAsDateWith(completion: @escaping (Date?) -> Void) { - DispatchQueue.global().async { - // slow calculations performed here - let date = Date(iso8601String: self.date) - DispatchQueue.main.async { - completion(date) - } - } - } - - func getLastUpdatedAsDate() -> Date? { - return Date(iso8601String: self.date) - } + func getLastUpdatedAsDateWith(completion: @escaping (Date?) -> Void) { + DispatchQueue.global().async { + // slow calculations performed here + let date = Date(iso8601String: self.date) + DispatchQueue.main.async { + completion(date) + } + } + } + + func getLastUpdatedAsDate() -> Date? { + return Date(iso8601String: self.date) + } } // Extension to go Encodable -> Dictionary extension Encodable { - var dictionary: [String: Any] { - return (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(self))) as? [String: Any] ?? [:] - } - var nsDictionary: NSDictionary { - return dictionary as NSDictionary - } + var dictionary: [String: Any] { + return (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(self))) as? [String: Any] ?? [:] + } + var nsDictionary: NSDictionary { + return dictionary as NSDictionary + } } - diff --git a/SEDaily-IOS/PodcastCollectionViewCell.swift b/SEDaily-IOS/PodcastCollectionViewCell.swift index cf84569..42a7e00 100644 --- a/SEDaily-IOS/PodcastCollectionViewCell.swift +++ b/SEDaily-IOS/PodcastCollectionViewCell.swift @@ -10,114 +10,123 @@ import UIKit import SnapKit import KTResponsiveUI import Skeleton -import SDWebImage +import Kingfisher class PodcastCell: UICollectionViewCell { - var imageView: UIImageView! - var titleLabel: UILabel! - var timeDayLabel: UILabel! - - var viewModel: PodcastViewModel = PodcastViewModel() { - willSet { - guard newValue != self.viewModel else { return } - } - didSet { - self.titleLabel.text = viewModel.podcastTitle - viewModel.getLastUpdatedAsDateWith { (date) in - self.setupTimeDayLabel(timeLength: nil, date: date) - } - self.setupImageView(imageURL: viewModel.featuredImageURL) - } - } - - override init(frame: CGRect) { - super.init(frame: frame) - let newContentView = UIView(width: 158, height: 250) - self.contentView.frame = newContentView.frame - - imageView = UIImageView(leftInset: 0, topInset: 4, width: 158) - self.contentView.addSubview(imageView) - imageView.contentMode = .scaleAspectFill - imageView.clipsToBounds = true - imageView.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 6) - - titleLabel = UILabel(origin: imageView.bottomLeftPoint(), topInset: 15, width: 158, height: 50) - self.contentView.addSubview(titleLabel) - titleLabel.numberOfLines = 0 - titleLabel.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 16)) - - timeDayLabel = UILabel(origin: titleLabel.bottomLeftPoint(), topInset: 8, width: 158, height: 14) - self.contentView.addSubview(timeDayLabel) - timeDayLabel.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 12)) - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:)") - } - - private func setupImageView(imageURL: URL?) { - guard let imageURL = imageURL else { - self.imageView.image = #imageLiteral(resourceName: "SEDaily_Logo") - return - } + var imageView: UIImageView! + var titleLabel: UILabel! + var miscDetailsLabel: UILabel! + + var viewModel: PodcastViewModel = PodcastViewModel() { + willSet { + guard newValue != self.viewModel else { return } + } + didSet { + self.titleLabel.text = viewModel.podcastTitle + viewModel.getLastUpdatedAsDateWith { (date) in + self.setupMiscDetailsLabel(timeLength: nil, date: date, isDownloaded: self.viewModel.isDownloaded) + } + self.setupImageView(imageURL: viewModel.featuredImageURL) + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + let newContentView = UIView(width: 158, height: 250) + self.contentView.frame = newContentView.frame + + imageView = UIImageView(leftInset: 0, topInset: 4, width: 158) + self.contentView.addSubview(imageView) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 6) + + + self.imageView.kf.indicatorType = .activity + + titleLabel = UILabel(origin: imageView.bottomLeftPoint(), topInset: 15, width: 158, height: 50) + self.contentView.addSubview(titleLabel) + titleLabel.numberOfLines = 0 + titleLabel.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 16)) + + miscDetailsLabel = UILabel(origin: titleLabel.bottomLeftPoint(), topInset: 8, width: 158, height: 14) + self.contentView.addSubview(miscDetailsLabel) + miscDetailsLabel.font = UIFont.systemFont(ofSize: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + - imageView.sd_setShowActivityIndicatorView(true) - imageView.sd_setIndicatorStyle(.gray) - imageView.sd_setImage(with: imageURL) - } - - private func setupTimeDayLabel(timeLength: Int?, date: Date?) { - let dateString = date?.dateString() ?? "" - timeDayLabel.text = dateString - } - - // MARK: Skeleton - var skeletonImageView: GradientContainerView! - var skeletonTitleLabel: GradientContainerView! - var skeletontimeDayLabel: GradientContainerView! - - private func setupSkeletonView() { - self.skeletonImageView = GradientContainerView(frame: self.imageView.frame) - self.skeletonImageView.cornerRadius = self.imageView.cornerRadius - self.skeletonImageView.backgroundColor = UIColor(red:0.87, green:0.87, blue:0.87, alpha:1.0) - self.contentView.addSubview(skeletonImageView) - skeletonTitleLabel = GradientContainerView(origin: imageView.bottomLeftPoint(), topInset: 15, width: 158, height: 14) - self.contentView.addSubview(skeletonTitleLabel) - skeletontimeDayLabel = GradientContainerView(origin: skeletonTitleLabel.bottomLeftPoint(), topInset: 15, width: 158, height: 14) - self.contentView.addSubview(skeletontimeDayLabel) - - let baseColor = self.skeletonImageView.backgroundColor! - let gradients = baseColor.getGradientColors(brightenedBy: 1.07) - self.skeletonImageView.gradientLayer.colors = gradients - self.skeletonTitleLabel.gradientLayer.colors = gradients - self.skeletontimeDayLabel.gradientLayer.colors = gradients - } - - func setupSkeletonCell() { - self.setupSkeletonView() - self.slide(to: .right) - } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + private func setupImageView(imageURL: URL?) { + self.imageView.kf.cancelDownloadTask() + guard let imageURL = imageURL else { + self.imageView.image = #imageLiteral(resourceName: "SEDaily_Logo") + return + } + + self.imageView.kf.setImage(with: imageURL, options: [.transition(.fade(0.2))]) + } + + private func setupMiscDetailsLabel(timeLength: Int?, date: Date?, isDownloaded: Bool) { + let dateString = date?.dateString() ?? "" + if isDownloaded { + miscDetailsLabel.text = "\(dateString) (Downloaded)" + } else { + miscDetailsLabel.text = dateString + } + + } + + // MARK: Skeleton + var skeletonImageView: GradientContainerView! + var skeletonTitleLabel: GradientContainerView! + var skeletontimeDayLabel: GradientContainerView! + + private func setupSkeletonView() { + self.skeletonImageView = GradientContainerView(frame: self.imageView.frame) + self.skeletonImageView.cornerRadius = self.imageView.cornerRadius + self.skeletonImageView.backgroundColor = UIColor(red: 0.87, green: 0.87, blue: 0.87, alpha: 1.0) + self.contentView.addSubview(skeletonImageView) + skeletonTitleLabel = GradientContainerView(origin: imageView.bottomLeftPoint(), topInset: 15, width: 158, height: 14) + self.contentView.addSubview(skeletonTitleLabel) + skeletontimeDayLabel = GradientContainerView(origin: skeletonTitleLabel.bottomLeftPoint(), topInset: 15, width: 158, height: 14) + self.contentView.addSubview(skeletontimeDayLabel) + + let baseColor = self.skeletonImageView.backgroundColor! + let gradients = baseColor.getGradientColors(brightenedBy: 1.07) + self.skeletonImageView.gradientLayer.colors = gradients + self.skeletonTitleLabel.gradientLayer.colors = gradients + self.skeletontimeDayLabel.gradientLayer.colors = gradients + } + + func setupSkeletonCell() { + self.setupSkeletonView() + self.slide(to: .right) + } } extension PodcastCell: GradientsOwner { - var gradientLayers: [CAGradientLayer] { - return [skeletonImageView.gradientLayer, - skeletonTitleLabel.gradientLayer, - skeletontimeDayLabel.gradientLayer - ] - } + var gradientLayers: [CAGradientLayer] { + return [skeletonImageView.gradientLayer, + skeletonTitleLabel.gradientLayer, + skeletontimeDayLabel.gradientLayer + ] + } } extension UIColor { - func brightened(by factor: CGFloat) -> UIColor { - var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - getHue(&h, saturation: &s, brightness: &b, alpha: &a) - return UIColor(hue: h, saturation: s, brightness: b * factor, alpha: a) - } - - func getGradientColors(brightenedBy: CGFloat) -> [Any] { - return [self.cgColor, - self.brightened(by: brightenedBy).cgColor, - self.cgColor] - } + func brightened(by factor: CGFloat) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * factor, alpha: a) + } + + func getGradientColors(brightenedBy: CGFloat) -> [Any] { + return [self.cgColor, + self.brightened(by: brightenedBy).cgColor, + self.cgColor] + } } diff --git a/SEDaily-IOS/PodcastCollectionViewController.swift b/SEDaily-IOS/PodcastCollectionViewController.swift index 613ea9e..14f02a2 100644 --- a/SEDaily-IOS/PodcastCollectionViewController.swift +++ b/SEDaily-IOS/PodcastCollectionViewController.swift @@ -27,14 +27,14 @@ class PodcastCollectionViewController: UICollectionViewController { self.collectionView?.backgroundColor = UIColor(hex: 0xfafafa) self.collectionView?.showsHorizontalScrollIndicator = false self.collectionView?.showsVerticalScrollIndicator = false - + let layout = KoalaTeaFlowLayout(ratio: 0.5, cellsAcross: 1, cellSpacing: 0) self.collectionView?.collectionViewLayout = layout - + // User Login observer NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) } - + @objc func loginObserver() { self.collectionView?.reloadData() } @@ -51,45 +51,45 @@ class PodcastCollectionViewController: UICollectionViewController { return 3 } - override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { // #warning Incomplete implementation, return the number of items return 1 } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! EmbeddedCollectonViewCell + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? EmbeddedCollectonViewCell else { + return UICollectionViewCell() + } // Configure the cell switch indexPath.section { case 1: - + cell.setupCell(type: API.Types.recommended, fromViewController: self) - + return cell case 2: cell.setupCell(type: API.Types.top, fromViewController: self) - + return cell default: cell.setupCell(type: API.Types.new, fromViewController: self) - + return cell } - + } } extension PodcastCollectionViewController { // MARK: Header - + override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { - + let reusableview = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withClass: CollectionReusableView.self, for: indexPath) - - + switch indexPath.section { case 1: reusableview?.setupTitleLabel(title: "Just For You") @@ -98,12 +98,12 @@ extension PodcastCollectionViewController { default: reusableview?.setupTitleLabel(title: "Latest") } - + return reusableview! } - + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - + return CGSize(width: self.collectionView!.width, height: 60.calculateHeight()) } } diff --git a/SEDaily-IOS/PodcastDataSource.swift b/SEDaily-IOS/PodcastDataSource.swift index dd2dc24..98af94d 100644 --- a/SEDaily-IOS/PodcastDataSource.swift +++ b/SEDaily-IOS/PodcastDataSource.swift @@ -9,122 +9,137 @@ import Foundation import Disk -protocol DataSource { - associatedtype T - - func getAll(completion: @escaping ([T]?) -> Void) - func getById(id: String, completion: @escaping (T?) -> Void) - func insert(item: T) - func update(item: T) - func clean() - func deleteById(id: String) -} +class PodcastDataSource { + typealias GenericType = Podcast + + + + + static func getRecentlyListenedEpisode(podcastId: String, diskKey: DiskKeys, completion: @escaping ([GenericType]?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let retrievedObjects = try? Disk.retrieve(diskKey.folderPath, from: .caches, as: [GenericType].self) + let recentlyListened = retrievedObjects?.filter({ podcast -> Bool in + return podcast._id == podcastId + }) + DispatchQueue.main.async { + completion(recentlyListened) + } + } + } -enum DiskKeys: String { - case PodcastFolder = "Podcasts" - - var folderPath: String { - return self.rawValue + "/" + self.rawValue + ".json" + static func getAllBookmarks(diskKey: DiskKeys, completion: @escaping ([GenericType]?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let retrievedObjects = try? Disk.retrieve(diskKey.folderPath, from: .caches, as: [GenericType].self) + let bookmarks = retrievedObjects?.filter({ podcast -> Bool in + return podcast.bookmarked == true + }) + DispatchQueue.main.async { + completion(bookmarks) + } + } } -} + + static func getAllDownloads(diskKey: DiskKeys, completion: @escaping ([GenericType]?) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { + let retrievedObjects = try? Disk.retrieve(diskKey.folderPath, from: .caches, as: [GenericType].self) + let downloads = retrievedObjects?.filter({ podcast -> Bool in + let vm = PodcastViewModel(podcast: podcast) + return vm.isDownloaded == true + }) + DispatchQueue.main.async { + completion(downloads) + } + } + } -class PodcastDataSource: DataSource { - typealias T = Podcast - - func getAll(completion: @escaping ([T]?) -> Void) { + static func getAll(diskKey: DiskKeys, completion: @escaping ([GenericType]?) -> Void) { DispatchQueue.global(qos: .userInitiated).async { - let retrievedObjects = try? Disk.retrieve(DiskKeys.PodcastFolder.folderPath, from: .caches, as: [T].self) + let retrievedObjects = try? Disk.retrieve(diskKey.folderPath, from: .caches, as: [GenericType].self) DispatchQueue.main.async { completion(retrievedObjects) } } } - - func getAllWith(filterObject: FilterObject, completion: @escaping ([T]?) -> Void) { - self.getAll { (returnedData) in + + static func getAllWith(diskKey: DiskKeys, filterObject: FilterObject, completion: @escaping ([GenericType]?) -> Void) { + self.getAll(diskKey: diskKey) { (returnedData) in DispatchQueue.global(qos: .userInitiated).async { - //@TODO: Guard - let filteredObjects = returnedData?.filter({ (podcast) -> Bool in + guard let filteredObjects = returnedData?.filter({ (podcast) -> Bool in return podcast.tags!.contains(filterObject.tags) && podcast.categories!.contains(filterObject.categories) && podcast.type == filterObject.type }) - + else { + completion(nil) + return + } + let dateString = filterObject.lastDate if let passedDate = Date(iso8601String: dateString) { - //@TODO: Gaurd - let dateFilteredObjects = filteredObjects?.filter({ (podcast) -> Bool in + let dateFilteredObjects = filteredObjects.filter({ (podcast) -> Bool in return podcast.getLastUpdatedAsDate()! < passedDate }) - //@TODO: Gaurd DispatchQueue.main.async { - completion(Array(dateFilteredObjects!.prefix(10))) - + completion(Array(dateFilteredObjects.prefix(10))) } return } DispatchQueue.main.async { - // Prefix = to max paging - completion(Array(filteredObjects!.prefix(10))) - + // Prefix = max paging + completion(Array(filteredObjects.prefix(10))) } return } } } - - func getById(id: String, completion: @escaping (T?) -> Void) { - self.getAll { (returnedData) in - let foundObject = returnedData?.filter({ (item) -> Bool in - return item._id == id - }).first - completion(foundObject) + + static func insert(diskKey: DiskKeys, items: [GenericType]) { + self.getAll(diskKey: .PodcastFolder) { (results) in + var newResults = results ?? [GenericType]() + items.forEach({ newPodcast in + if let index = results?.index(where: { oldPodcast -> Bool in + return newPodcast._id == oldPodcast._id + }) { + newResults[index] = newPodcast + } else { + newResults.append(newPodcast) + } + }) + + self.override(diskKey: diskKey, items: newResults) } - } - - func insert(item: T) { - //@TODO: When would this fail + + static func update(diskKey: DiskKeys, item: GenericType) { DispatchQueue.global(qos: .userInitiated).async { - do { - try Disk.append(item, to: DiskKeys.PodcastFolder.folderPath, in: .caches) - } catch { - //@TODO: Handle errors? - // ... + self.getAll(diskKey: .PodcastFolder) { (results) in + var newResults = results ?? [GenericType]() + if let index = results?.index(where: { oldPodcast -> Bool in + return item._id == oldPodcast._id + }) { + newResults[index] = item + } else { + newResults.append(item) + } + + self.override(diskKey: diskKey, items: newResults) } } } - - func insert(items: [T]) { - DispatchQueue.global(qos: .userInitiated).async { - do { - try Disk.append(items, to: DiskKeys.PodcastFolder.folderPath, in: .caches) - } catch { - //@TODO: Handle errors? - // ... - } + + static func override(diskKey: DiskKeys, items: [GenericType]) { + do { + try Disk.save(items, to: .caches, as: diskKey.folderPath) + } catch let error { + log.error(error.localizedDescription) } } - - func update(item: T) { - - } - - func clean() { - try? Disk.remove(DiskKeys.PodcastFolder.rawValue, from: .caches) - } - - func deleteById(id: String) { - + + static func clean(diskKey: DiskKeys) { + do { + try Disk.remove(diskKey.folderPath, from: .caches) + } catch let error { + log.error(error.localizedDescription) + } } - - //@TODO: We may need to check if items exist? - // func checkIfExists(item: Podcast) { - // self.getById(id: item._id) { (returnedItem) in - // if returnedItem != nil { - // log.info("not nil") - // } - // log.info("nil?") - // } - // } } diff --git a/SEDaily-IOS/PodcastDescriptionView.swift b/SEDaily-IOS/PodcastDescriptionView.swift index 24e23ef..03d704f 100644 --- a/SEDaily-IOS/PodcastDescriptionView.swift +++ b/SEDaily-IOS/PodcastDescriptionView.swift @@ -11,19 +11,19 @@ import ActiveLabel import KTResponsiveUI class PodcastDescriptionView: UIView { - + private lazy var label: ActiveLabel = { return ActiveLabel(leftInset: 15, topInset: 15, width: 375 - 30, height: 600) }() - + private let bottomMarginForLabel = UIView.getValueScaledByScreenHeightFor(baseValue: 30) - + override func performLayout() { self.backgroundColor = Stylesheet.Colors.offWhite - + self.addSubview(label) self.height = label.height - + label.numberOfLines = 0 label.enabledTypes = [.url] label.textColor = Stylesheet.Colors.offBlack @@ -37,10 +37,13 @@ class PodcastDescriptionView: UIView { } } } - + func setupView(podcastModel: PodcastViewModel) { podcastModel.getHTMLDecodedDescription { (returnedString) in - self.label.text = returnedString + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = 10 + + self.label.attributedText = NSAttributedString(string: returnedString, attributes: [NSAttributedStringKey.paragraphStyle: paragraphStyle]) self.label.sizeToFit() self.height = self.label.height + self.bottomMarginForLabel } diff --git a/SEDaily-IOS/PodcastDetailViewController.swift b/SEDaily-IOS/PodcastDetailViewController.swift index 270726d..d358419 100644 --- a/SEDaily-IOS/PodcastDetailViewController.swift +++ b/SEDaily-IOS/PodcastDetailViewController.swift @@ -7,45 +7,246 @@ // import UIKit +import WebKit +import SwiftIcons -protocol PodcastDetailViewControllerDelegate { +protocol PodcastDetailViewControllerDelegate: class { func modelDidChange(viewModel: PodcastViewModel) } -class PodcastDetailViewController: UIViewController { - var delegate: PodcastDetailViewControllerDelegate? - +protocol BookmarksDelegate: class { + func bookmarkPodcast() + +} + +class PodcastDetailViewController: UIViewController, WKNavigationDelegate { + + weak var delegate: PodcastDetailViewControllerDelegate? + private weak var audioOverlayDelegate: AudioOverlayDelegate? + + private var bookmarkButton: UIButton? + + let networkService: API = API() + var model = PodcastViewModel() - + lazy var scrollView: UIScrollView = { return UIScrollView(frame: self.view.frame) }() + required init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?, audioOverlayDelegate: AudioOverlayDelegate?) { + self.audioOverlayDelegate = audioOverlayDelegate + super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) + } + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func viewDidLoad() { super.viewDidLoad() self.view.backgroundColor = Stylesheet.Colors.base - self.view.addSubview(scrollView) - + let headerView = HeaderView(width: 375, height: 200) headerView.setupHeader(model: model) headerView.delegate = self - self.scrollView.addSubview(headerView) - - let view = PodcastDescriptionView(origin: headerView.bottomLeftPoint(),width: 375, height: 20) - view.setupView(podcastModel: model) - scrollView.addSubview(view) - view.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, - constant: UIView.getValueScaledByScreenHeightFor(baseValue: -65)).isActive = true + headerView.bookmarkDelegate = self + headerView.audioOverlayDelegate = self.audioOverlayDelegate + + let webView = WKWebView() + webView.navigationDelegate = self + self.view.addSubview(webView) + webView.snp.makeConstraints { (make) in + make.left.right.top.bottom.equalToSuperview() + } + + var htmlString = self.removePowerPressPlayerTags(html: model.encodedPodcastDescription) + htmlString = self.addStyling(html: htmlString) + htmlString = self.addHeightAdjustment(html: htmlString, height: headerView.height) + htmlString = self.addScaleMeta(html: htmlString) + webView.loadHTMLString(htmlString, baseURL: nil) + + webView.scrollView.addSubview(headerView) + + let iconSize = UIView.getValueScaledByScreenHeightFor(baseValue: 25) + self.bookmarkButton = UIButton() + self.bookmarkButton?.addTarget(self, action: #selector(self.bookmarkButtonPressed), for: .touchUpInside) + self.bookmarkButton?.setIcon( + icon: .fontAwesome(.bookmarkO), + iconSize: iconSize, + color: Stylesheet.Colors.white, + forState: .normal) + self.bookmarkButton?.setIcon( + icon: .fontAwesome(.bookmark), + iconSize: iconSize, + color: Stylesheet.Colors.white, + forState: .selected) + self.bookmarkButton?.isSelected = self.model.isBookmarked + if let bookmarkButton = self.bookmarkButton { + let bookmarkBarButtonItem = UIBarButtonItem(customView: bookmarkButton) + self.navigationItem.rightBarButtonItem = bookmarkBarButtonItem + } + Analytics2.podcastPageViewed(podcastId: model._id) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.audioOverlayDelegate?.setCurrentShowingDetailView( + podcastViewModel: self.model) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.audioOverlayDelegate?.setCurrentShowingDetailView( + podcastViewModel: nil) + } + + private func setBookmarked(_ bool: Bool) { + self.model.isBookmarked = bool + self.bookmarkButton?.isSelected = bool + } + + private func removePowerPressPlayerTags(html: String) -> String { + var modifiedHtml = html + guard let powerPressPlayerRange = modifiedHtml.range(of: "") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(powerPressPlayerRange) + + ///////////////////////// + guard let divStartRange = modifiedHtml.range(of: "
") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(divStartRange.lowerBound..") else { + return modifiedHtml + } + guard let pEndRange = modifiedHtml.range(of: "

") else { + return modifiedHtml + } + modifiedHtml.removeSubrange(pStartRange.lowerBound.. String { + return "\(html)" + } + + private func addHeightAdjustment(html: String, height: CGFloat) -> String { + return "
\(html)" + } + + private func addScaleMeta(html: String) -> String { + return "\(html)" } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url, + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } else { + decisionHandler(.allow) + } + } } extension PodcastDetailViewController: HeaderViewDelegate { + + func relatedLinksButtonPressed() { + Analytics2.relatedLinksButtonPressed(podcastId: model._id) + let relatedLinksStoryboard = UIStoryboard.init(name: "RelatedLinks", bundle: nil) + guard let relatedLinksViewController = relatedLinksStoryboard.instantiateViewController( + withIdentifier: "RelatedLinksViewController") as? RelatedLinksViewController else { + return + } + let podcastId = model._id + relatedLinksViewController.postId = podcastId + self.navigationController?.pushViewController(relatedLinksViewController, animated: true) + } + + func commentsButtonPressed() { + Analytics2.podcastCommentsViewed(podcastId: model._id) + let commentsStoryboard = UIStoryboard.init(name: "Comments", bundle: nil) + guard let commentsViewController = commentsStoryboard.instantiateViewController( + withIdentifier: "CommentsViewController") as? CommentsViewController else { + return + } + if let thread = model.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } + func modelDidChange(viewModel: PodcastViewModel) { self.delegate?.modelDidChange(viewModel: viewModel) } } + +extension PodcastDetailViewController: BookmarksDelegate { + @objc private func bookmarkButtonPressed() { + guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) + return + } + + guard let bookmarkButton = self.bookmarkButton else { + log.error("There is no bookmark button") + return + } + + bookmarkButton.isSelected = !bookmarkButton.isSelected + self.setBookmark(value: bookmarkButton.isSelected) + + } + + private func setBookmark(value: Bool) { + let podcastId = model._id + networkService.setBookmarkPodcast( + value: value, + podcastId: podcastId, + completion: { (success, active) in + guard success != nil else { return } + if success == true { + guard let active = active else { return } + self.updateBookmarked(active: active) + } + }) + Analytics2.bookmarkButtonPressed(podcastId: model._id) + } + + func bookmarkPodcast() { + if !model.isBookmarked { + setBookmark(value: true) + } + } + + func updateBookmarked(active: Bool) { + self.setBookmarked(active) + self.model.isBookmarked = active + self.delegate?.modelDidChange(viewModel: self.model) + // Update the bookmark button too with actual result: + + guard let bookmarkButton = self.bookmarkButton else { + log.error("There is no bookmark button") + return + } + + bookmarkButton.isSelected = active + + } + +} + diff --git a/SEDaily-IOS/PodcastLite.swift b/SEDaily-IOS/PodcastLite.swift new file mode 100644 index 0000000..64c851a --- /dev/null +++ b/SEDaily-IOS/PodcastLite.swift @@ -0,0 +1,29 @@ +// +// PodcastLite.swift +// SEDaily-IOS +// +// Created by jason on 4/27/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +public struct PodcastLite: Codable { + let _id: String + let thread: String? + let title: String + let rendered:String + let featuredImage: String? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let titleHolder = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .title) + rendered = try titleHolder.decode(String.self, forKey: .rendered) + + featuredImage = try container.decode(String.self, forKey: .featuredImage) + _id = try container.decode(String.self, forKey: ._id) + thread = try container.decode(String.self, forKey: .thread) + title = "" + } +} diff --git a/SEDaily-IOS/PodcastPageViewController.swift b/SEDaily-IOS/PodcastPageViewController.swift index 8c202e2..1a26c2c 100644 --- a/SEDaily-IOS/PodcastPageViewController.swift +++ b/SEDaily-IOS/PodcastPageViewController.swift @@ -10,54 +10,73 @@ import UIKit import Tabman import Pageboy -class PodcastPageViewController: TabmanViewController, PageboyViewControllerDataSource { - +class PodcastPageViewController: TabmanViewController, PageboyViewControllerDataSource, MainCoordinated { + + var mainCoordinator: MainFlowCoordinator? + + var viewControllers = [GeneralCollectionViewController]() var barItems = [TabmanBar.Item]() var customTabBarItem: UITabBarItem! { - get { - return UITabBarItem(title: L10n.tabBarTitleLatest, image: #imageLiteral(resourceName: "mic_stand"), selectedImage: #imageLiteral(resourceName: "mic_stand_selected")) - } + return UITabBarItem(title: L10n.tabBarTitleLatest, image: UIImage(named: "latest_outline"), selectedImage: UIImage(named: "latest")) + } + + init() { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() self.tabBarItem = customTabBarItem - + self.dataSource = self - + self.loadViewControllers() - + // configure the bar self.bar.style = .scrollingButtonBar - + + bar.appearance = TabmanBar.Appearance({ (appearance) in + appearance.style.background = .solid(color: UIColor.white) + appearance.indicator.color = Stylesheet.Colors.base + appearance.state.selectedColor = Stylesheet.Colors.base + }) + + self.bar.items = barItems - + self.reloadPages() - + // Set the tab bar controller first selected item here self.tabBarController?.selectedIndex = 0 } - + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { return viewControllers.count } - + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { return viewControllers[index] } - + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { return nil } - + func loadViewControllers() { let layout = UICollectionViewLayout() - + viewControllers = [ GeneralCollectionViewController(collectionViewLayout: layout, tabTitle: PodcastCategoryIds.All.description), + GeneralCollectionViewController(collectionViewLayout: layout, + categories: [PodcastCategoryIds.Greatest_Hits], + tabTitle: PodcastCategoryIds.Greatest_Hits.description), GeneralCollectionViewController(collectionViewLayout: layout, categories: [PodcastCategoryIds.Business_and_Philosophy], tabTitle: PodcastCategoryIds.Business_and_Philosophy.description), @@ -84,14 +103,12 @@ class PodcastPageViewController: TabmanViewController, PageboyViewControllerData tabTitle: PodcastCategoryIds.Security.description), GeneralCollectionViewController(collectionViewLayout: layout, categories: [PodcastCategoryIds.Hackers], - tabTitle: PodcastCategoryIds.Hackers.description), - GeneralCollectionViewController(collectionViewLayout: layout, - categories: [PodcastCategoryIds.Greatest_Hits], - tabTitle: PodcastCategoryIds.Greatest_Hits.description) + tabTitle: PodcastCategoryIds.Hackers.description) ] - + viewControllers.forEach { (controller) in barItems.append(Item(title: controller.tabTitle)) + mainCoordinator?.configure(viewController: controller) } } } diff --git a/SEDaily-IOS/PodcastRepository.swift b/SEDaily-IOS/PodcastRepository.swift index 1202447..a6870a9 100644 --- a/SEDaily-IOS/PodcastRepository.swift +++ b/SEDaily-IOS/PodcastRepository.swift @@ -19,66 +19,157 @@ protocol RepositoryProtocol { public class Repository: NSObject, RepositoryProtocol { typealias DataModel = T - + internal var lastReturnedDataArray: [DataModel] = [] } enum RepositoryError: Error { case ErrorGettingFromAPI - case ErrorGettingFromRealm + case ErrorGettingFromDisk case ReturnedDataEqualsLastData case ReturnedDataIsZero } - + class PodcastRepository: Repository { typealias RepositorySuccessCallback = ([DataModel]) -> Void typealias RepositoryErrorCallback = (RepositoryError) -> Void - - private let dataSource = PodcastDataSource() - + typealias DataSource = PodcastDataSource + + let networkService = API() + // MARK: Getters With Paging let tag = "podcasts" - var loading = false - - func getData(page: Int = 0, - filterObject: FilterObject, - onSucces: @escaping RepositorySuccessCallback, - onFailure: @escaping RepositoryErrorCallback) { - self.retrieveDataFromRealmOrAPI(filterObject: filterObject, onSucces: { (returnedData) in - onSucces(returnedData) - }) { (error) in - onFailure(error) + + /// Retrieves the cached downloads data from disk + /// + /// - Parameters: + /// - onSuccess: Success callback + /// - onFailure: Failure callback + func retrieveDownloadsData( + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + DataSource.getAllDownloads(diskKey: .PodcastFolder) { diskData in + guard let data = diskData else { + onFailure(.ErrorGettingFromDisk) + return + } + onSuccess(data) + } + } + + func retrieveRecentlyListened( + podcastId: String, + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + DataSource.getRecentlyListenedEpisode(podcastId: podcastId, diskKey: .PodcastFolder) { diskData in + guard let data = diskData else { + onFailure(.ErrorGettingFromDisk) + return + } + onSuccess(data) + } + } + + + + + /// Retrieves the cached bookmark data from disk + /// + /// - Parameters: + /// - onSuccess: Success callback + /// - onFailure: Failure callback + func retrieveCachedBookmarkData( + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + DataSource.getAllBookmarks(diskKey: .PodcastFolder) { diskData in + guard let data = diskData else { + onFailure(.ErrorGettingFromDisk) + return + } + onSuccess(data) + } + } + + /// Retrieves bookmark data by making a network call + /// + /// - Parameters: + /// - onSuccess: Success callback + /// - onFailure: Failure callback + func retrieveNetworkBookmarkData( + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + networkService.podcastBookmarks { (success, results) in + if success == true { + guard let podcasts = results else { + onFailure(.ErrorGettingFromAPI) + return + } + DataSource.insert(diskKey: .PodcastFolder, items: podcasts) + onSuccess(podcasts) + } else { + onFailure(.ErrorGettingFromAPI) + } + } + } + + func getData( + diskKey: DiskKeys, + filterObject: FilterObject?, + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + + switch diskKey { + case .PodcastFolder: + self.retrievePodcastData( + filterObject: filterObject, + onSuccess: { (returnedData) in + onSuccess(returnedData) }, + onFailure: { (error) in + PodcastRepository.clearLoadedToday() + onFailure(error) }) + case .OfflineDownloads: + break } } - + // MARK: Disk and API data getter - private func retrieveDataFromRealmOrAPI(filterObject: FilterObject, - onSucces: @escaping RepositorySuccessCallback, - onFailure: @escaping RepositoryErrorCallback) { + private func retrievePodcastData( + filterObject: FilterObject?, + onSuccess: @escaping RepositorySuccessCallback, + onFailure: @escaping RepositoryErrorCallback) { + + guard let filterObject = filterObject else { + onFailure(.ErrorGettingFromDisk) + return + } + // Check if we made requests today - let alreadLoadedStartToday = self.checkAlreadyLoadedNewToday(filterObject: filterObject) +// let alreadLoadedStartToday = PodcastRepository.checkAlreadyLoadedNewToday(filterObject: filterObject) + let alreadLoadedStartToday = false + //@TODO: Fix this special case for recommneded. We can't load from disk here because we are display top podcasts when a user is not logged in if alreadLoadedStartToday && filterObject.type != PodcastTypes.recommended.rawValue { self.loading = true log.warning("from disk") // Check if we have realm data saved - self.dataSource.getAllWith(filterObject: filterObject, completion: { (returnedData) in + DataSource.getAllWith(diskKey: .PodcastFolder, filterObject: filterObject, completion: { (returnedData) in guard let data = returnedData, !data.isEmpty else { self.loading = false - onFailure(.ErrorGettingFromRealm) + onFailure(.ErrorGettingFromDisk) return } - guard data != self.lastReturnedDataArray else { + //@TODO: check how to clear this or remove completely + if self.returnedDataEqualLastData(returnedData: data) { self.loading = false onFailure(.ReturnedDataEqualsLastData) return } - - self.setLoadedNewToday(filterObject: filterObject) + + PodcastRepository.setLoadedNewToday(filterObject: filterObject) self.lastReturnedDataArray = data self.loading = false - onSucces(data) + onSuccess(data) }) return } @@ -87,43 +178,71 @@ class PodcastRepository: Repository { self.loading = true // API Call and return - API.sharedInstance.getPosts(type: filterObject.type, createdAtBefore: filterObject.lastDate, tags: filterObject.tagsAsString, categories: filterObject.categoriesAsString, onSucces: { (podcasts) in - self.loading = false - guard podcasts != self.lastReturnedDataArray else { - onFailure(.ReturnedDataEqualsLastData) - return - } - self.dataSource.insert(items: podcasts) - self.setLoadedNewToday(filterObject: filterObject) - self.lastReturnedDataArray = podcasts - onSucces(podcasts) - }) { (apiError) in - self.loading = false - onFailure(.ErrorGettingFromAPI) - } + networkService.getPosts( + type: filterObject.type, + createdAtBefore: filterObject.lastDate, + tags: filterObject.tagsAsString, + categories: filterObject.categoriesAsString, + onSuccess: { (podcasts) in + self.loading = false + if self.returnedDataEqualLastData(returnedData: podcasts) { + onFailure(.ReturnedDataEqualsLastData) + return + } + DataSource.insert(diskKey: .PodcastFolder, items: podcasts) + PodcastRepository.setLoadedNewToday(filterObject: filterObject) + self.lastReturnedDataArray = podcasts + onSuccess(podcasts) }, + onFailure: { _ in + self.loading = false + onFailure(.ErrorGettingFromAPI) + }) } - + // MARK: Already loaded today checks - func checkAlreadyLoadedNewToday(filterObject: FilterObject) -> Bool { - let key = "\(APICheckDates.newFeedLastCheck)-\(filterObject.dictionary)" + static func checkAlreadyLoadedNewToday(filterObject: FilterObject) -> Bool { + let key = "\(APICheckDates.newFeedLastCheck)-\(filterObject.nsDictionary)" let defaults = UserDefaults.standard if let newFeedLastCheck = defaults.string(forKey: key) { let todayDate = Date().dateString() let newFeedDate = Date(iso8601String: newFeedLastCheck)!.dateString() - if (newFeedDate == todayDate) { + if newFeedDate == todayDate { return true } - + return false } return false } - - func setLoadedNewToday (filterObject: FilterObject) { + + static func setLoadedNewToday (filterObject: FilterObject) { let todayString = Date().iso8601String - let key = "\(APICheckDates.newFeedLastCheck)-\(filterObject.dictionary)" + let key = "\(APICheckDates.newFeedLastCheck)-\(filterObject.nsDictionary)" let defaults = UserDefaults.standard defaults.set(todayString, forKey: key) } + + static func clearLoadedToday() { + let defaults = UserDefaults.standard + let keys = defaults.dictionaryRepresentation() + for key in keys { + if key.key.contains(APICheckDates.newFeedLastCheck) { + defaults.removeObject(forKey: key.key) + } + } + } + + func returnedDataEqualLastData(returnedData: [DataModel]) -> Bool { + guard returnedData != self.lastReturnedDataArray else { + return true + } + return false + } +} + +extension PodcastRepository { + func updateDataSource(diskKey: DiskKeys, item: DataModel) { + DataSource.update(diskKey: diskKey, item: item) + } } diff --git a/SEDaily-IOS/PodcastTableViewCell.swift b/SEDaily-IOS/PodcastTableViewCell.swift index 7b27bcd..3ddfb51 100644 --- a/SEDaily-IOS/PodcastTableViewCell.swift +++ b/SEDaily-IOS/PodcastTableViewCell.swift @@ -1,52 +1,313 @@ +//// +//// PodcastTableViewCell.swift +//// SEDaily-IOS +//// +//// Created by Craig Holliday on 9/8/17. +//// Copyright © 2017 Koala Tea. All rights reserved. +//// +// +//import Foundation +//import AVFoundation +// +//import UIKit +//import SnapKit +//import KTResponsiveUI +//import Skeleton +//import Kingfisher +//import Reusable +// +//class PodcastTableViewCell: UITableViewCell, Reusable { +// var episodeImage: UIImageView! +// var imageOverlay: UIView! +// var titleLabel: UILabel! +// var miscDetailsLabel: UILabel! +// var descriptionLabel: UILabel! +// +// var actionView: ActionView! +// +// var commentShowCallback: (()-> Void) = {} +// +// let upvoteCountLabel: UILabel = UILabel() +// +// let upvoteStackView: UIStackView = UIStackView() +// +// let progressBar: UIProgressView = UIProgressView() +// +// // MARK: Skeleton +// +// +// +// +// var viewModel: PodcastViewModel = PodcastViewModel() { +// willSet { +// guard newValue != self.viewModel else { return } +// } +// didSet { +// updateUI() +// } +// } +// +// var upvoteService: UpvoteService? +// var bookmarkService: BookmarkService? +// +// var playProgress: PlayProgress? +// +// override init(style: UITableViewCellStyle, reuseIdentifier: String?) { +// super.init(style: style, reuseIdentifier: reuseIdentifier) +// setupLayout() +// setupButtonsTargets() +// } +// required init +// (coder aDecoder: NSCoder) { +// fatalError("init(coder:)") +// } +// //MARK: Button handlers +// +// private func setupButtonsTargets() { +// actionView.upvoteButton.addTarget(self, action: #selector(PodcastTableViewCell.upvoteTapped), for: .touchUpInside) +// actionView.bookmarkButton.addTarget(self, action: #selector(PodcastTableViewCell.bookmarkTapped), for: .touchUpInside) +// actionView.commentButton.addTarget(self, action: #selector(PodcastTableViewCell.commentTapped), for: .touchUpInside) +// } +// +// @objc func upvoteTapped() { +// let impact = UIImpactFeedbackGenerator() +// impact.impactOccurred() +// +// upvoteService?.UIDelegate = self +// upvoteService?.upvote() +// } +// +// @objc func bookmarkTapped() { +// let selection = UISelectionFeedbackGenerator() +// selection.selectionChanged() +// +// bookmarkService?.UIDelegate = self +// bookmarkService?.setBookmark() +// } +// +// @objc func commentTapped() { +// let notification = UINotificationFeedbackGenerator() +// notification.notificationOccurred(.success) +// commentShowCallback() +// } +// +// +//} +// +//extension PodcastTableViewCell: UpvoteServiceUIDelegate { +// func upvoteUIDidChange(isUpvoted: Bool, score: Int) { +// actionView.upvoteButton.isSelected = isUpvoted +// actionView.upvoteCountLabel.text = String(score) +// updateLabelStyle() +// } +// +// func upvoteUIImmediateUpdate() { +// guard let tempScore = Int(actionView.upvoteCountLabel.text ?? "0") else { return } +// actionView.upvoteCountLabel.text = actionView.upvoteButton.isSelected ? String(tempScore - 1) : String(tempScore + 1) +// actionView.upvoteButton.isSelected = !actionView.upvoteButton.isSelected +// updateLabelStyle() +// } +//} +// +//extension PodcastTableViewCell { +// func updateLabelStyle() { +// actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark +// actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) : UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) +// } +//} +// +//extension PodcastTableViewCell: BookmarkServiceUIDelegate { +// func bookmarkUIDidChange(isBookmarked: Bool) { +// actionView.bookmarkButton.isSelected = isBookmarked +// } +// func bookmarkUIImmediateUpdate() { +// actionView.bookmarkButton.isSelected = !actionView.bookmarkButton.isSelected +// } +//} +// +//extension PodcastTableViewCell { +// private func setupLayout() { +// +// backgroundColor = .white +// +// func setupEpisodeImage() { +// episodeImage = UIImageView() +// contentView.addSubview(episodeImage) +// episodeImage.contentMode = .scaleAspectFill +// episodeImage.clipsToBounds = true +// episodeImage.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 5) +// episodeImage.kf.indicatorType = .activity +// +// imageOverlay = UIView() +// contentView.addSubview(imageOverlay) +// imageOverlay.clipsToBounds = true +// imageOverlay.cornerRadius = UIView.getValueScaledByScreenHeightFor(baseValue: 5) +// imageOverlay.backgroundColor = Stylesheet.Colors.lightTransparent +// } +// +// func setupLabels() { +// titleLabel = UILabel() +// contentView.addSubview(titleLabel) +// titleLabel.numberOfLines = 3 +// titleLabel.font = UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 17)) +// titleLabel.textColor = Stylesheet.Colors.dark +// +// miscDetailsLabel = UILabel() +// contentView.addSubview(miscDetailsLabel) +// miscDetailsLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 11)) +// miscDetailsLabel.textColor = Stylesheet.Colors.dark +// +// descriptionLabel = UILabel() +// descriptionLabel.numberOfLines = 2 +// descriptionLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) +// descriptionLabel.textColor = Stylesheet.Colors.dark +// contentView.addSubview(descriptionLabel) +// } +// +// +// func setupProgressBar() { +// progressBar.progressTintColor = Stylesheet.Colors.base +// progressBar.trackTintColor = Stylesheet.Colors.gray +// progressBar.transform = progressBar.transform.scaledBy(x: 1, y: 1) +// progressBar.isHidden = true +// contentView.addSubview(progressBar) +// } +// +// func setupActionView() { +// actionView = ActionView() +// actionView.setupComponents(superview: contentView) +// } +// +// +// func setupConstraints() { +// episodeImage.snp.makeConstraints { (make) -> Void in +// make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:15)) +// make.top.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:10)) +// make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 80)) +// make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 80)) +// } +// +// +// imageOverlay.snp.makeConstraints { (make) -> Void in +// make.left.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:15)) +// make.top.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue:10)) +// make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue:80)) +// make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue:80)) +// } +// +// titleLabel.snp.makeConstraints { (make) -> Void in +// make.top.equalTo(episodeImage) +// make.rightMargin.equalTo(contentView).inset(UIView.getValueScaledByScreenWidthFor(baseValue:15.0)) +// make.left.equalTo(episodeImage.snp.right).offset(UIView.getValueScaledByScreenWidthFor(baseValue:10.0)) +// } +// +// miscDetailsLabel.snp.makeConstraints { (make) -> Void in +// make.top.equalTo(titleLabel.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue:5.0)) +// make.left.equalTo(titleLabel) +// } +// +// descriptionLabel.snp.makeConstraints { (make) -> Void in +// make.top.equalTo(episodeImage.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue:10.0)) +// make.rightMargin.equalTo(contentView).inset(UIView.getValueScaledByScreenWidthFor(baseValue:15.0)) +// make.left.equalTo(episodeImage) +// } +// actionView.setupContraints() +// +// actionView.actionStackView.snp.makeConstraints { (make) -> Void in +// make.top.equalTo(descriptionLabel.snp.bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue:10.0)) +// make.bottom.equalTo(contentView) +// make.left.equalTo(episodeImage) +// } +// +// progressBar.snp.makeConstraints { (make) -> Void in +// make.width.equalTo(contentView) +// make.rightMargin.equalTo(contentView) +// make.leftMargin.equalTo(contentView) +// make.bottom.equalTo(contentView) +// } +// } +// +// setupEpisodeImage() +// setupLabels() +// setupProgressBar() +// setupActionView() +// setupConstraints() +// } +//} +// +//extension PodcastTableViewCell { +// private func updateUI() { +// +// self.titleLabel.text = viewModel.podcastTitle +// +// func loadepisodeImage(imageURL: URL?) { +// episodeImage.kf.cancelDownloadTask() +// guard let imageURL = imageURL else { +// episodeImage.image = #imageLiteral(resourceName: "SEDaily_Logo") +// return +// } +// episodeImage.kf.setImage(with: imageURL, options: [.transition(.fade(0.2))]) +// } +// +// func setupMiscDetailsLabel(timeLength: Int?, date: Date?, isDownloaded: Bool) { +// let dateString = date?.dateString() ?? "" +// let timeLeftString = isProgressSet() ? createTimeLeftString() : "" +// miscDetailsLabel.text = dateString + timeLeftString +// } +// +// func createTimeLeftString()->String { +// return " · " + Helpers.createTimeString(time: playProgress?.timeLeft ?? 0.0, units: [.minute]) + " " + L10n.timeLeft +// } +// +// func setupDescriptionLabel() { +// var str: String! +// // Due to asynchronuous nature of decoding html content, this is a better way to do it +// DispatchQueue.global(qos: .background).async { [weak self] in +// str = self?.viewModel.podcastDescription +// DispatchQueue.main.async { +// +// self?.descriptionLabel.text = str +// } +// } +// +// } +// +// func updateUpvote() { +// actionView.upvoteCountLabel.text = String(viewModel.score) +// actionView.upvoteButton.isSelected = viewModel.isUpvoted +// actionView.upvoteCountLabel.textColor = actionView.upvoteButton.isSelected ? Stylesheet.Colors.base : Stylesheet.Colors.dark +// actionView.upvoteCountLabel.font = actionView.upvoteButton.isSelected ? UIFont(name: "OpenSans-Semibold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) : UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) +// } +// +// func updateBookmark() { +// actionView.bookmarkButton.isSelected = viewModel.isBookmarked +// } +// +// func updateProgressBar() { +// guard let playProgress = playProgress else { return } +// if isProgressSet() { +// progressBar.progress = playProgress.progressFraction +// progressBar.isHidden = false +// } else { +// progressBar.isHidden = true +// } +// } +// +// func isProgressSet()->Bool { +// guard let playProgress = playProgress else { return false } +// return playProgress.progressFraction > Float(0.005) +// } +// +// loadepisodeImage(imageURL: viewModel.featuredImageURL) +// viewModel.getLastUpdatedAsDateWith { [weak self] (date) in +// guard let strongSelf = self else { return } +// setupMiscDetailsLabel(timeLength: nil, date: date, isDownloaded: strongSelf.viewModel.isDownloaded) +// } +// updateProgressBar() +// setupDescriptionLabel() +// updateUpvote() +// updateBookmark() +// } +//} +// // -// PodcastTableViewCell.swift -// SEDaily-IOS -// -// Created by Craig Holliday on 9/8/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -import UIKit -import Reusable -import SnapKit -import SwifterSwift -import KTResponsiveUI -import SDWebImage - -class PodcastTableViewCell: UITableViewCell, Reusable { - private var cellLabel: UILabel! - private var cellImageView: UIImageView! - - var viewModel: PodcastViewModel = PodcastViewModel() { - willSet { - guard newValue != self.viewModel else { return } - } - didSet { - self.cellLabel.text = viewModel.podcastTitle - - if let url = viewModel.featuredImageURL { - cellImageView.sd_setImage(with: url) - } - } - } - - override init(style: UITableViewCellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - cellImageView = UIImageView(leftInset: 10, height: 75) - cellImageView.image = #imageLiteral(resourceName: "SEDaily_Logo") - cellImageView.contentMode = .scaleAspectFit - cellImageView.clipsToBounds = true - - cellLabel = UILabel(origin: cellImageView.topRightPoint(), leftInset: 10, width: 250, height: 75) - cellLabel.textColor = .black - cellLabel.baselineAdjustment = .alignCenters - cellLabel.numberOfLines = 0 - - self.contentView.addSubview(cellImageView) - self.contentView.addSubview(cellLabel) - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:)") - } -} diff --git a/SEDaily-IOS/PodcastViewModel.swift b/SEDaily-IOS/PodcastViewModel.swift index cfecaed..2400854 100644 --- a/SEDaily-IOS/PodcastViewModel.swift +++ b/SEDaily-IOS/PodcastViewModel.swift @@ -9,113 +9,147 @@ import Foundation public struct PodcastViewModel: Codable { - let _id: String - let uploadDateiso8601: String - let postLinkURL: URL? - let categories: [Int]? - var categoriesAsString: String { - get { - guard let categories = self.categories else { return "" } - let stringArray = categories.map { String(describing: $0) } - return stringArray.joined(separator: " ") - } - } - let tags: [Int]? - var tagsAsString: String { - get { - guard let tags = self.tags else { return "" } - let stringArray = tags.map { String(describing: $0) } - return stringArray.joined(separator: " ") - } - } - let mp3URL: URL? - let featuredImageURL: URL? - private let encodedPodcastTitle: String - private let encodedPodcastDescription: String - var score: Int - var isUpvoted: Bool = false - var isDownvoted: Bool = false - - var podcastTitle: String { - get { - return encodedPodcastTitle.htmlDecoded - } - } - - init(podcast: Podcast) { - self._id = podcast._id - self.uploadDateiso8601 = podcast.date - self.postLinkURL = URL(string: podcast.link) - self.categories = podcast.categories - self.tags = podcast.tags - self.mp3URL = URL(string: podcast.mp3) - self.featuredImageURL = URL(string: podcast.featuredImage ?? "") - self.encodedPodcastTitle = podcast.title.rendered - self.encodedPodcastDescription = podcast.content.rendered - self.score = podcast.score - - if let upvoted = podcast.upvoted { - self.isUpvoted = upvoted - } - if let downvoted = podcast.downvoted { - self.isDownvoted = downvoted - } - } - - init() { - self._id = "" - self.uploadDateiso8601 = "" - self.postLinkURL = nil - self.categories = [] - self.tags = [] - self.mp3URL = nil - self.featuredImageURL = nil - self.encodedPodcastTitle = "" - self.encodedPodcastDescription = "" - self.score = 0 - } + let _id: String + let thread: ForumThreadLite? + let uploadDateiso8601: String + let postLinkURL: URL? + let categories: [Int]? + var categoriesAsString: String { + guard let categories = self.categories else { return "" } + let stringArray = categories.map { String(describing: $0) } + return stringArray.joined(separator: " ") + } + let tags: [Int]? + var tagsAsString: String { + guard let tags = self.tags else { return "" } + let stringArray = tags.map { String(describing: $0) } + return stringArray.joined(separator: " ") + } + let mp3URL: URL? + let featuredImageURL: URL? + let guestImageURL: URL? + let encodedPodcastTitle: String + let encodedPodcastDescription: String + var score: Int + var isUpvoted: Bool = false + var isDownvoted: Bool = false + var isBookmarked: Bool = false + var isDownloaded: Bool { + return downloadedFileURLString != "" && downloadedFileURLString != nil + } + var downloadedFileURLString: String? { + guard let url = OfflineDownloadsManager.findURL(for: self) else { + return nil + } + return url.path + } + + var podcastTitle: String { + return encodedPodcastTitle.htmlDecoded + } + + var podcastDescription: String { + return HtmlHelper.removePowerPressPlayerTags(html: encodedPodcastDescription).htmlDecoded + } + + var downloadingProgress: Int? + + init(podcast: Podcast) { + self._id = podcast._id + self.thread = podcast.thread + self.uploadDateiso8601 = podcast.date + self.postLinkURL = URL(string: podcast.link) + self.categories = podcast.categories + self.tags = podcast.tags + self.mp3URL = URL(string: podcast.mp3) + self.featuredImageURL = URL(string: podcast.featuredImage ?? "") + self.guestImageURL = URL(string: podcast.guestImage ?? "") + self.encodedPodcastTitle = podcast.title.rendered + self.encodedPodcastDescription = podcast.content.rendered + self.score = 0 + if let podScore = podcast.score { + self.score = podScore + } + + if let upvoted = podcast.upvoted { + self.isUpvoted = upvoted + } + if let downvoted = podcast.downvoted { + self.isDownvoted = downvoted + } + + if let bookmarked = podcast.bookmarked { + self.isBookmarked = bookmarked + } + } + + init() { + self._id = "" + self.thread = nil + self.uploadDateiso8601 = "" + self.postLinkURL = nil + self.categories = [] + self.tags = [] + self.mp3URL = nil + self.featuredImageURL = nil + self.guestImageURL = nil + self.encodedPodcastTitle = "" + self.encodedPodcastDescription = "" + self.score = 0 + } + + var baseModelRepresentation: Podcast { + return Podcast(viewModel: self) + } } extension PodcastViewModel: Equatable { - public static func ==(lhs: PodcastViewModel, rhs: PodcastViewModel) -> Bool { - return lhs._id == rhs._id && - lhs.uploadDateiso8601 == rhs.uploadDateiso8601 && - lhs.postLinkURL == rhs.postLinkURL && - lhs.categories ?? [] == rhs.categories ?? [] && - lhs.tags ?? [] == rhs.tags ?? [] && - lhs.mp3URL == rhs.mp3URL && - lhs.featuredImageURL == rhs.featuredImageURL && - lhs.encodedPodcastTitle == rhs.encodedPodcastTitle && - lhs.encodedPodcastDescription == rhs.encodedPodcastDescription && - lhs.score == rhs.score - } + public static func == (lhs: PodcastViewModel, rhs: PodcastViewModel) -> Bool { + return lhs._id == rhs._id && + lhs.uploadDateiso8601 == rhs.uploadDateiso8601 && + lhs.postLinkURL == rhs.postLinkURL && + lhs.categories ?? [] == rhs.categories ?? [] && + lhs.tags ?? [] == rhs.tags ?? [] && + lhs.mp3URL == rhs.mp3URL && + lhs.featuredImageURL == rhs.featuredImageURL && + lhs.guestImageURL == rhs.guestImageURL + lhs.encodedPodcastTitle == rhs.encodedPodcastTitle && + lhs.encodedPodcastDescription == rhs.encodedPodcastDescription && + lhs.score == rhs.score + } } extension PodcastViewModel { - func getLastUpdatedAsDateWith(completion: @escaping (Date?) -> Void) { - DispatchQueue.global().async { - // slow calculations performed here - let date = Date(iso8601String: self.uploadDateiso8601) - DispatchQueue.main.async { - completion(date) - } - } - } - - // This is too slow for a cell collection view call - func getLastUpdatedAsDate() -> Date? { - return Date(iso8601String: self.uploadDateiso8601) - } + func getLastUpdatedAsDateWith(completion: @escaping (Date?) -> Void) { + DispatchQueue.global().async { + // slow calculations performed here + let date = Date(iso8601String: self.uploadDateiso8601) + DispatchQueue.main.async { + completion(date) + } + } + } + + // This is too slow for a cell collection view call + func getLastUpdatedAsDate() -> Date? { + return Date(iso8601String: self.uploadDateiso8601) + } } extension PodcastViewModel { - func getHTMLDecodedDescription(completion: @escaping (String) -> Void) { - DispatchQueue.global().async { - // slow calculations performed here - let decodedString = self.encodedPodcastDescription.htmlDecodedWithSomeEntities ?? "" - DispatchQueue.main.async { - completion(decodedString) - } - } - } + func getFilename() -> String { + return self.podcastTitle.lowercased().components(separatedBy: CharacterSet.alphanumerics.inverted).joined() + } +} + +extension PodcastViewModel { + func getHTMLDecodedDescription(completion: @escaping (String) -> Void) { + DispatchQueue.global().async { + // slow calculations performed here + let decodedString = self.encodedPodcastDescription.htmlDecodedWithSomeEntities ?? "" + DispatchQueue.main.async { + completion(decodedString) + } + } + } } diff --git a/SEDaily-IOS/PodcastViewModelController.swift b/SEDaily-IOS/PodcastViewModelController.swift index 148eb10..80872e7 100644 --- a/SEDaily-IOS/PodcastViewModelController.swift +++ b/SEDaily-IOS/PodcastViewModelController.swift @@ -19,23 +19,25 @@ public class PodcastViewModelController { typealias ViewModel = PodcastViewModel typealias SuccessCallback = () -> Void typealias ErrorCallback = (RepositoryError?) -> Void - + fileprivate let repository = PodcastRepository() fileprivate var viewModels: [ViewModel?] = [] + let networkService = API() + var viewModelsCount: Int { return viewModels.count } - + func viewModel(at index: Int) -> ViewModel? { guard index >= 0 && index < viewModelsCount else { return nil } return viewModels[index] } - + func clearViewModels() { self.viewModels.removeAll() } - + func update(with podcast: PodcastViewModel) { let index = self.viewModels.index { (item) -> Bool in return item?._id == podcast._id @@ -43,93 +45,146 @@ public class PodcastViewModelController { guard let modelsIndex = index else { return } self.viewModels.remove(at: modelsIndex) self.viewModels.insert(podcast, at: modelsIndex) + + // Tell repository to update Datasource + self.repository.updateDataSource(diskKey: .PodcastFolder, item: podcast.baseModelRepresentation) } - + func fetchData(type: String = "", createdAtBefore beforeDate: String = "", tags: [Int] = [], categories: [Int] = [], - page: Int = 0, clearData: Bool = false, - onSucces: @escaping SuccessCallback, + onSuccess: @escaping SuccessCallback, onFailure: @escaping ErrorCallback) { if clearData { self.clearViewModels() } let filterObject = FilterObject(type: type, tags: tags, lastDate: beforeDate, categories: categories) - repository.getData(filterObject: filterObject, onSucces: { (podcasts) in - let newViewModels: [ViewModel?] = podcasts.map { model in - return ViewModel(podcast: model) - } - guard !self.viewModels.isEmpty else { - self.viewModels.append(contentsOf: newViewModels) - onSucces() - return - } - - //@TODO: Do this in the background? - let filteredArray = newViewModels.filter { newPodcast in - let contains = self.viewModels.contains { currentPodcast in - return newPodcast == currentPodcast + repository.getData( + diskKey: .PodcastFolder, + filterObject: filterObject, + onSuccess: { (podcasts) in + let newViewModels: [ViewModel?] = podcasts.map { model in + return ViewModel(podcast: model) } - return !contains - } - - guard filteredArray.count != 0 else { - // OnFailure Nothing to append - //@TODO: Change handle error - onFailure(.ReturnedDataIsZero) - return - } - - self.viewModels.append(contentsOf: filteredArray) - onSucces() - }) { (error) in - //@TODO: make this not api error - onFailure(error) - } + guard !self.viewModels.isEmpty else { + self.viewModels.append(contentsOf: newViewModels) + onSuccess() + return + } + + //@TODO: Do this in the background? + let filteredArray = newViewModels.filter { newPodcast in + let contains = self.viewModels.contains { currentPodcast in + return newPodcast == currentPodcast + } + return !contains + } + + guard filteredArray.count != 0 else { + // OnFailure Nothing to append + //@TODO: Change handle error + onFailure(.ReturnedDataIsZero) + return + } + + self.viewModels.append(contentsOf: filteredArray) + onSuccess() }, + onFailure: { (error) in + // If there is no data, clear loaded today and clear last returned data + if self.viewModelsCount == 0 { + self.repository.lastReturnedDataArray.removeAll() + PodcastRepository.clearLoadedToday() + } + onFailure(error) }) } - + func fetchSearchData(searchTerm: String, createdAtBefore beforeDate: String = "", firstSearch: Bool, - onSucces: @escaping SuccessCallback, + onSuccess: @escaping SuccessCallback, onFailure: @escaping (APIError?) -> Void) { if firstSearch { self.clearViewModels() } - API.sharedInstance.getPostsWith(searchTerm: searchTerm, createdAtBefore: beforeDate, onSucces: { (podcasts) in - let newViewModels: [ViewModel?] = podcasts.map { model in - return ViewModel(podcast: model) - } - - guard !self.viewModels.isEmpty else { - self.viewModels.append(contentsOf: newViewModels) - onSucces() - return - } - - //@TODO: Do this in the background? - let filteredArray = newViewModels.filter { newPodcast in - let contains = self.viewModels.contains { currentPodcast in - return newPodcast == currentPodcast + networkService.getPostsWith( + searchTerm: searchTerm, + createdAtBefore: beforeDate, + onSuccess: { (podcasts) in + let newViewModels: [ViewModel?] = podcasts.map { model in + return ViewModel(podcast: model) } - return !contains - } - - guard filteredArray.count != 0 else { - // OnFailure Nothing to append - //@TODO: Change handle error - onFailure(.GeneralFailure) - return - } - - self.viewModels.append(contentsOf: filteredArray) - onSucces() - }) { (apiError) in - //@TODO: handle error - log.error(apiError?.localizedDescription) - onFailure(apiError) - } + + + guard !self.viewModels.isEmpty else { + self.viewModels.append(contentsOf: newViewModels) + onSuccess() + return + } + + //@TODO: Do this in the background? + let filteredArray = newViewModels.filter { newPodcast in + let contains = self.viewModels.contains { currentPodcast in + return newPodcast == currentPodcast + } + return !contains + } + + guard filteredArray.count != 0 else { + // OnFailure Nothing to append + //@TODO: Change handle error + onFailure(.GeneralFailure) + return + } + + self.viewModels.append(contentsOf: filteredArray) + onSuccess() }, + onFailure: { (apiError) in + //@TODO: handle error + log.error(apiError?.localizedDescription ?? "") + onFailure(apiError) }) } + + func fetchTopicData(slug: String, + createdAtBefore beforeDate: String = "", + onSuccess: @escaping SuccessCallback, + onFailure: @escaping (APIError?) -> Void) { + networkService.getPostsFor( + topic: slug, + createdAtBefore: beforeDate, + onSuccess: { (podcasts) in + let newViewModels: [ViewModel?] = podcasts.map { model in + return ViewModel(podcast: model) + } + + guard !self.viewModels.isEmpty else { + self.viewModels.append(contentsOf: newViewModels) + onSuccess() + return + } + + //@TODO: Do this in the background? + let filteredArray = newViewModels.filter { newPodcast in + let contains = self.viewModels.contains { currentPodcast in + return newPodcast == currentPodcast + } + return !contains + } + + guard filteredArray.count != 0 else { + // OnFailure Nothing to append + //@TODO: Change handle error + onFailure(.GeneralFailure) + return + } + + self.viewModels.append(contentsOf: filteredArray) + onSuccess() }, + onFailure: { (apiError) in + //@TODO: handle error + log.error(apiError?.localizedDescription ?? "") + onFailure(apiError) }) + } + } diff --git a/SEDaily-IOS/PostsForTopicCollectionViewController.swift b/SEDaily-IOS/PostsForTopicCollectionViewController.swift new file mode 100644 index 0000000..7cb6ec0 --- /dev/null +++ b/SEDaily-IOS/PostsForTopicCollectionViewController.swift @@ -0,0 +1,241 @@ + +// +// PostsForTopicTableViewController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/16/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + + +import UIKit + + +private let reuseIdentifier = "Cell" + +class PostsForTopicCollectionViewController: UICollectionViewController, MainCoordinated { + var mainCoordinator: MainFlowCoordinator? + + lazy var skeletonCollectionView: SkeletonCollectionView = { + return SkeletonCollectionView(frame: self.collectionView!.frame) + }() + + + var topic: Topic + + private var progressController = PlayProgressModelController() + + // Paging Properties + var loading = false + let pageSize = 10 + let preloadMargin = 5 + + var lastLoadedPage = 0 + var errorChecks = 0 + let maximumErrorChecks = 5 + + + // ViewModelController + private let podcastViewModelController: PodcastViewModelController = PodcastViewModelController() + + init(collectionViewLayout layout: UICollectionViewLayout, + topic: Topic = Topic(_id: "", name: "", slug: "", status: "", postCount: 0) + ) { + + self.topic = topic + super.init(collectionViewLayout: layout) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.title = topic.name + // Uncomment the following line to preserve selection between presentations + // self.clearsSelectionOnViewWillAppear = false + + + // Register cell classes + self.collectionView?.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + //hardcoded height + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light + + // User Login observer + NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + self.collectionView?.addSubview(skeletonCollectionView) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + progressController.retrieve() + self.collectionView?.reloadData() + } + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Make sure skeletonCollectionView is animating when the view is visible + if self.skeletonCollectionView.alpha != 0 { + self.skeletonCollectionView.collectionView.reloadData() + } + } + + @objc func loginObserver() { + self.podcastViewModelController.clearViewModels() + DispatchQueue.main.async { + self.collectionView?.reloadData() + } + self.getData(lastIdentifier: "", nextPage: 0) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + // MARK: UICollectionViewDataSource + + override func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + if podcastViewModelController.viewModelsCount > 0 { + self.skeletonCollectionView.fadeOut(duration: 0.5, completion: nil) + } + if podcastViewModelController.viewModelsCount <= 0 { + // Load initial data + self.getData(lastIdentifier: "", nextPage: 0) + } + return podcastViewModelController.viewModelsCount + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + + // Configure the cell + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + + let upvoteService = UpvoteService(podcastViewModel: viewModel) + let bookmarkService = BookmarkService(podcastViewModel: viewModel) + let downloadService = DownloadService(podcastViewModel: viewModel) + + cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) + + + cell.viewModel = viewModel + cell.upvoteService = upvoteService + cell.bookmarkService = bookmarkService + + cell.commentShowCallback = { [weak self] in + self?.commentsButtonPressed(viewModel) + + } + + if let lastIndexPath = self.collectionView?.indexPathForLastItem { + if let lastItem = podcastViewModelController.viewModel(at: lastIndexPath.row) { + self.checkPage(currentIndexPath: indexPath, + lastIndexPath: lastIndexPath, + lastIdentifier: lastItem.uploadDateiso8601) + } + } + } + + return cell + } + + func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { + let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 + let preloadIndex = nextPage * self.pageSize - self.preloadMargin + + if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { + // @TODO: Turn lastIdentifier into some T + self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage) + } + } + + func getData(lastIdentifier: String, nextPage: Int) { + guard self.loading == false else { return } + self.loading = true + podcastViewModelController.fetchTopicData( + slug: topic._id, + createdAtBefore: lastIdentifier, + onSuccess: { [weak self] in + self?.errorChecks = 0 + self?.loading = false + self?.lastLoadedPage = nextPage + DispatchQueue.main.async { + self?.collectionView?.reloadData() + } }, + onFailure: { [weak self] (apiError) in + self?.loading = false + self?.errorChecks += 1 + log.error(apiError ?? "") + guard let strongSelf = self else { return } + guard strongSelf.errorChecks <= strongSelf.maximumErrorChecks else { return } + }) + } + + // MARK: UICollectionViewDelegate + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + + let vc = EpisodeViewController() + mainCoordinator?.configure(viewController: vc) + vc.viewModel = viewModel + self.navigationController?.pushViewController(vc, animated: true) + + } + } +} + +extension PostsForTopicCollectionViewController { + private func viewModelDidChange(viewModel: PodcastViewModel) { + self.podcastViewModelController.update(with: viewModel) + } +} + + + +extension PostsForTopicCollectionViewController { + func commentsButtonPressed(_ viewModel: PodcastViewModel) { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } +} + +extension PostsForTopicCollectionViewController { + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + viewModelDidChange(viewModel: viewModel) + } + } + } +} diff --git a/SEDaily-IOS/ProfileCell.swift b/SEDaily-IOS/ProfileCell.swift new file mode 100644 index 0000000..62d5903 --- /dev/null +++ b/SEDaily-IOS/ProfileCell.swift @@ -0,0 +1,163 @@ +// +// ProfileCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/7/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable + +class ProfileCell: UITableViewCell, Reusable { + + var avatarImage: UIImageView! + var nameLabel: UILabel! + var bioLabel: UILabel! + var linkLabel: UILabel! + var usernameOrEmailLabel: UILabel! + var separator: UIView! + + var viewModel: ViewModel = ViewModel() { + didSet { + nameLabel.text = viewModel.name + bioLabel.text = viewModel.bio + linkLabel.text = viewModel.link + usernameOrEmailLabel.text = viewModel.username + } + } + @objc func linkTapped() { + // open link here + } + + + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + } + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + + // Configure the view for the selected state + } + + func setupAvatar(imageURL: URL?) { + avatarImage.kf.cancelDownloadTask() + guard let imageURL = imageURL else { + avatarImage.image = #imageLiteral(resourceName: "SEDaily_Logo") + return + } + avatarImage.kf.setImage(with: imageURL, options: [.transition(.fade(0.2))]) + } + +} + +extension ProfileCell { + private func setupLayout() { + func setupAvatarImage() { + avatarImage = UIImageView() + contentView.addSubview(avatarImage) + avatarImage.contentMode = .scaleAspectFill + avatarImage.clipsToBounds = true + avatarImage.borderWidth = UIView.getValueScaledByScreenWidthFor(baseValue: 1) + avatarImage.borderColor = Stylesheet.Colors.base + avatarImage.cornerRadius = UIView.getValueScaledByScreenWidthFor(baseValue: 50) + avatarImage.kf.indicatorType = .activity + } + func setupLabels() { + nameLabel = UILabel() + contentView.addSubview(nameLabel) + nameLabel.textColor = Stylesheet.Colors.dark + nameLabel.numberOfLines = 3 + nameLabel.font = UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 30)) + + usernameOrEmailLabel = UILabel() + contentView.addSubview(usernameOrEmailLabel) + usernameOrEmailLabel.textColor = Stylesheet.Colors.grey + usernameOrEmailLabel.numberOfLines = 0 + usernameOrEmailLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 11)) + + + bioLabel = UILabel() + contentView.addSubview(bioLabel) + bioLabel.textColor = Stylesheet.Colors.dark + bioLabel.numberOfLines = 0 + bioLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + + linkLabel = UILabel() + contentView.addSubview(linkLabel) + linkLabel.textColor = Stylesheet.Colors.base + linkLabel.numberOfLines = 0 + let tap = UITapGestureRecognizer(target: self, action: #selector(ProfileCell.linkTapped)) + linkLabel.isUserInteractionEnabled = true + linkLabel.addGestureRecognizer(tap) + linkLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + } + + func setupSeparator() { + separator = UIView() + contentView.addSubview(separator) + separator.backgroundColor = Stylesheet.Colors.light + } + + func setupConstraints() { + avatarImage.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.width.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 100.0)) + make.height.equalTo(UIView.getValueScaledByScreenWidthFor(baseValue: 100.0)) + make.centerX.equalToSuperview() + } + nameLabel.snp.makeConstraints { (make) -> Void in + make.top.equalTo(avatarImage.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerX.equalTo(avatarImage.snp_centerX) + } + usernameOrEmailLabel.snp.makeConstraints { (make) -> Void in + make.centerX.equalTo(avatarImage.snp_centerX) + make.top.equalTo(nameLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 5.0)) + + } + bioLabel.snp.makeConstraints { (make) -> Void in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.top.equalTo(usernameOrEmailLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + + } + linkLabel.snp.makeConstraints { (make) -> Void in + make.left.equalTo(bioLabel) + make.top.equalTo(bioLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + } + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(5.0) + } + } + setupAvatarImage() + setupLabels() + setupSeparator() + setupConstraints() + } +} + + +extension ProfileCell { + struct ViewModel { + var avatarURL: String = "" + var name: String = "" + var username: String = "" + var bio: String = "" + var link: String = "" + } +} + + diff --git a/SEDaily-IOS/ProfileTableViewDataSource.swift b/SEDaily-IOS/ProfileTableViewDataSource.swift new file mode 100644 index 0000000..5f73ef6 --- /dev/null +++ b/SEDaily-IOS/ProfileTableViewDataSource.swift @@ -0,0 +1,173 @@ +// +// ProfileTableViewDataSource.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/4/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import Reusable + +import UIKit + +class ProfileTableViewDataSource: NSObject { + private let user: User + private let organizer: DataOrganizer + + init(user: User) { + self.user = user + organizer = DataOrganizer(user: user) + } + + func section(at index: Int) -> ProfileViewController.Section { + return organizer.section(at: index) + } + + func row(at indexPath: IndexPath) -> RowType { + return organizer.row(at: indexPath) + } +} + +// MARK: UITableViewDataSource +extension ProfileTableViewDataSource: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return organizer.sectionsCount + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 2: return "Settings" + default: return "Dummy" + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return organizer.rowsCount(for: section) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = organizer.row(at: indexPath) + guard let cell = tableView.dequeueReusableCell(with: row.cellType, for: indexPath) else { + return UITableViewCell() + } + + let section = organizer.section(at: indexPath.section) + + if let configurableCell = cell as? UserConfigurable { + configurableCell.configureWith(user: user, row: row) + } + if let configurableCell = cell as? Configurable { + configurableCell.configureWith(row: row) + } + return cell + } +} + + +// MARK: - DataOrganizer +extension ProfileTableViewDataSource { + struct DataOrganizer { + private let sections: [ProfileViewController.Section] + + var sectionsCount: Int { + return sections.count + } + + init(user: User) { + var sections: [ProfileViewController.Section] = [] + if user.username.isEmpty { + sections = [ + .summary([.signInPlaceholder]) + ] + } else { + sections = [ + .summary([.avatar, .name, .username, .bio, .link].filter { user[$0] != nil }), + .layout([.separator]), + .settings([.notifications, .editProfile].filter { $0 != nil && user.isMainUser }) + ] + } + + self.sections = sections.filter({ !$0.rows.isEmpty }) + } + + func section(at index: Int) -> ProfileViewController.Section { + return sections[index] + } + + func rowsCount(for section: Int) -> Int { + return sections[section].rows.count + } + + func row(at indexPath: IndexPath) -> RowType { + return sections[indexPath.section].rows[indexPath.row] + } + } +} + +// MARK: RowConfigurable +protocol UserConfigurable { + func configureWith(user: User, row: RowType) +} + +protocol Configurable { + func configureWith(row: RowType) +} + +extension AvatarCell: UserConfigurable { + func configureWith(user: User, row: RowType) { + avatarURL = user[.avatar] as? URL ?? nil + } +} + +extension SummaryCell: UserConfigurable { + func configureWith(user: User, row: RowType) { + guard let row = row as? ProfileViewController.Section.SummaryRow else { + assertionFailure("SummaryCell needs a row of type SummaryRow") + return + } + if user.username.isEmpty { + viewModel = ViewModel(text: "Sign in to view your profile" as? String ?? "", style: row.style) + } else { + viewModel = ViewModel(text: user[row] as? String ?? "", style: row.style) + } + } +} + +extension SwitchCell: Configurable { + func configureWith(row: RowType) { + guard let row = row as? ProfileViewController.Section.SettingsRow else { + assertionFailure("SwitchCell needs a row of type SettingsRow") + return + } + // Refactor when more switch cells are available + switch row { + case .notifications: + let notificationsController = NotificationsController() + if notificationsController.notificationsSubscribed { + toggle.setOn(true, animated: true) + } + viewModel = ViewModel(text: L10n.enableNotifications) + default: + return + } + } +} + +extension LabelCell: Configurable { + func configureWith(row: RowType) { + guard let row = row as? ProfileViewController.Section.SettingsRow else { + assertionFailure("LabelCell needs a row of type SettingsRow") + return + } + // Refactor when more label cells are available + switch row { + case .editProfile: + viewModel = ViewModel(text: L10n.editProfile, style: row.style) + default: + return + } + } +} + + diff --git a/SEDaily-IOS/ProfileViewController.swift b/SEDaily-IOS/ProfileViewController.swift new file mode 100644 index 0000000..f6b200a --- /dev/null +++ b/SEDaily-IOS/ProfileViewController.swift @@ -0,0 +1,267 @@ +// +// ProfileViewController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/7/19. +// Copyright © 2019 Altalogy All rights reserved. + + +import UIKit +import UserNotifications + +class ProfileViewController: UIViewController { + + private var dataSource: ProfileTableViewDataSource? + + var tableView: UITableView = UITableView.init(frame: CGRect.zero, style: .grouped) + + var user: User? + + let notificationsController = NotificationsController() + + init() { + super.init(nibName: nil, bundle: nil) + self.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(named: "person_outline"), selectedImage: UIImage(named: "person")) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(tableView) + setupDataSource() + tableView.delegate = self + + tableView.rowHeight = UITableViewAutomaticDimension + tableView.estimatedRowHeight = 50.0 + tableView.tableFooterView = UIView() + tableView.allowsSelection = true + tableView.separatorColor = .clear + tableView.backgroundColor = .white + tableView.register(cellType: SummaryCell.self) + tableView.register(cellType: AvatarCell.self) + tableView.register(cellType: SeparatorCell.self) + tableView.register(cellType: SwitchCell.self) + tableView.register(cellType: LabelCell.self) + NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) + tableView.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview() + make.bottom.equalToSuperview() + make.right.equalToSuperview() + make.left.equalToSuperview() + } + } + @objc func loginObserver() { + setupDataSource() + tableView.reloadData() + } +} +extension ProfileViewController { + private func setupDataSource() { + let user = self.user ?? UserManager.sharedInstance.getActiveUser() + let dataSource = ProfileTableViewDataSource(user: user) + self.dataSource = dataSource + tableView.dataSource = dataSource + } +} + +extension ProfileViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return dataSource?.section(at: section).headerHeight ?? 0 + } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + view.tintColor = .clear + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + switch cell { + case let cell as SwitchCell: cell.delegate = self + default: break + } + } + + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + dataSource.map { dataSource in + let row = dataSource.row(at: indexPath) + (row as? Section.SummaryRow).map { row in + switch row { + case .link: user?.website.map { //only for guest user + if let linkUrl = URL(string: URLSchemaHelper.addSchema(url: $0)) { + UIApplication.shared.open(linkUrl, options: [:], completionHandler: nil) + } + } + default: break + } + } + (row as? Section.SettingsRow).map { row in + switch row { + case .editProfile: + let alert = UIAlertController(title: "Please visit the web version", message: "We are working hard to bring this feature to mobile. Please visit softwaredaily.com to edit your profile", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + self.present(alert, animated: true) + default: break + } + } + } + } +} + +extension ProfileViewController: SwitchCellDelegate { + func switchCell(_ cell: SwitchCell, didToggle value: Bool) { + if value { + notificationsController.assignNotifications() + notificationsController.notificationsSubscribed = true + } else { + // cancel + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + notificationsController.notificationsSubscribed = false + } + + let defaults = UserDefaults.standard + defaults.set(notificationsController.notificationsSubscribed, forKey: notificationsController.notificationPrefKey) + } +} + +extension ProfileViewController { + private func assignNotifications () { + notificationsController.center.getNotificationSettings { [weak self] (settings) in + if settings.authorizationStatus != .authorized { + // Notifications not allowed + self?.notificationsController.requestNotifications() + } else { + self?.notificationsController.createNotification() + } + } + } +} + + +extension ProfileViewController { + enum Section { + case summary([SummaryRow]) + case layout([LayoutRow]) + case settings([SettingsRow]) + //case actions([ActionRow]) + + var rows: [RowType] { + switch self { + case let .summary(rows): return rows + case let .layout(rows): return rows + case let .settings(rows): return rows + //case let .actions(rows): return rows + } + } + + var headerHeight: CGFloat { + switch self { + case .settings: return 24.0 + case .summary, .layout: return 0.0 + } + } + } +} + +protocol RowType { + var cellType: UITableViewCell.Type { get } +} + +extension ProfileViewController.Section { + enum LayoutRow: RowType { + case separator + + var cellType: UITableViewCell.Type { + switch self { + case .separator: return SeparatorCell.self + } + } + } + + enum SettingsRow: RowType { + case notifications + case editProfile + + var cellType: UITableViewCell.Type { + switch self { + case .notifications: return SwitchCell.self + case .editProfile: return LabelCell.self + } + } + + + + + var style: LabelCell.ViewModel.Style { + switch self { + case .editProfile: return LabelCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + font: UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15))!, + color: Stylesheet.Colors.dark, + alignment: .left, + accessory: .disclosureIndicator) + case .notifications: + return LabelCell.ViewModel.Style() + } + } + } + + enum SummaryRow: RowType { + case avatar + case name + case username + case bio + case link + case signInPlaceholder + + var style: SummaryCell.ViewModel.Style { + switch self { + case .name: return SummaryCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 5.0), + font: UIFont(name: "Roboto-Bold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 30))!, + color: Stylesheet.Colors.dark, + alignment: .center) + case .username: return SummaryCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + font: UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 11))!, + color: Stylesheet.Colors.grey, + alignment: .center) + case .link: return SummaryCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 5.0), + font: UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13))!, + color: Stylesheet.Colors.base, + alignment: .left) + case .bio: return SummaryCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 5.0), + font: UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13))!, + color: Stylesheet.Colors.dark, + alignment: .left) + case .avatar: + //assertionFailure("There is no style for the avata row") + return SummaryCell.ViewModel.Style() + case .signInPlaceholder: return SummaryCell.ViewModel.Style( + marginX: UIView.getValueScaledByScreenWidthFor(baseValue: 15.0), + marginY: UIView.getValueScaledByScreenWidthFor(baseValue: 50.0), + font: UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13))!, + color: Stylesheet.Colors.dark, + alignment: .center) + } + } + + var cellType: UITableViewCell.Type { + switch self { + case .avatar: return AvatarCell.self + case .name, .username, .bio, .link, .signInPlaceholder: return SummaryCell.self + } + } + } +} diff --git a/SEDaily-IOS/ProgressIndicator.swift b/SEDaily-IOS/ProgressIndicator.swift new file mode 100644 index 0000000..6fec6a4 --- /dev/null +++ b/SEDaily-IOS/ProgressIndicator.swift @@ -0,0 +1,24 @@ +// +// ProgressIndicator.swift +// SEDaily-IOS +// +// Created by Justin Lam on 11/23/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import Foundation +import MBProgressHUD + +class ProgressIndicator { + static func showBlockingProgress() { + if let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window { + MBProgressHUD.showAdded(to: window, animated: true) + } + } + + static func hideBlockingProgress() { + if let app = UIApplication.shared.delegate as? AppDelegate, let window = app.window { + MBProgressHUD.hide(for: window, animated: true) + } + } +} diff --git a/SEDaily-IOS/PurchaseSubscriptionViewController.swift b/SEDaily-IOS/PurchaseSubscriptionViewController.swift new file mode 100644 index 0000000..06051b4 --- /dev/null +++ b/SEDaily-IOS/PurchaseSubscriptionViewController.swift @@ -0,0 +1,175 @@ +// +// PurchaseSubscriptionViewController.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 1/15/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +//import UIKit +//import Stripe +// +//class PurchaseSubscriptionViewController: UIViewController, STPAddCardViewControllerDelegate { +// +// let api = API.sharedInstance +// +// var planType: StripeParams.Plans? +// var monthlyContainerView = UIView() +// var yearlyContainerView = UIView() +// +// override func viewDidLoad() { +// super.viewDidLoad() +// +// self.view.backgroundColor = .white +// +// let leftBarButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(self.dismissSelf)) +// self.navigationItem.leftBarButtonItem = leftBarButton +// +// self.title = "Purchase Subscription" +// +// self.setupMonthlyContainer() +// self.setupYearlyContainer() +// } +// +// override func viewDidDisappear(_ animated: Bool) { +// api.loadUserInfo() +// } +// +// override func didReceiveMemoryWarning() { +// super.didReceiveMemoryWarning() +// // Dispose of any resources that can be recreated. +// } +// +// func setupMonthlyContainer() { +// self.monthlyContainerView = UIView(leftInset: 20, topInset: 20, width: 335, height: 160) +// self.monthlyContainerView.layer.shadowColor = UIColor.lightGray.cgColor +// self.monthlyContainerView.layer.shadowRadius = 4 +// self.monthlyContainerView.layer.shadowOpacity = 1 +// self.monthlyContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) +// self.monthlyContainerView.backgroundColor = .white +// self.monthlyContainerView.layer.cornerRadius = monthlyContainerView.height / 16 +// self.view.addSubview(monthlyContainerView) +// +// let typeLabel = UILabel(leftInset: 0, topInset: 30, width: 335, height: 40) +// typeLabel.text = "Monthly" +// let fontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 34) +// typeLabel.font = UIFont.systemFont(ofSize: fontSize, weight: .bold) +// typeLabel.textColor = Stylesheet.Colors.base +// typeLabel.textAlignment = .center +// +// monthlyContainerView.addSubview(typeLabel) +// +// let selectButton = UIButton(leftInset: 0, topInset: 0, width: 160, height: 40) +// let leftInset = monthlyContainerView.center.x - 20 - (selectButton.width / 2) +// let topInset = typeLabel.frame.maxY + UIView.getValueScaledByScreenHeightFor(baseValue: 15) +// selectButton.x = leftInset +// selectButton.y = topInset +// selectButton.setTitle("$10/Month", for: .normal) +// selectButton.setTitleColor(.white, for: .normal) +// selectButton.backgroundColor = Stylesheet.Colors.secondaryColor +// selectButton.cornerRadius = selectButton.height / 2 +// selectButton.addTarget(self, action: #selector(self.monthlyButtonPressed), for: .touchUpInside) +// monthlyContainerView.addSubview(selectButton) +// } +// +// @objc func monthlyButtonPressed() { +// self.planType = .monthly +// self.handleAddPaymentMethodButtonTapped() +// } +// +// func setupYearlyContainer() { +// self.yearlyContainerView = UIView(leftInset: 20, topInset: 0, width: 335, height: 160) +// self.yearlyContainerView.y = self.monthlyContainerView.frame.maxY + 20 +// +// self.yearlyContainerView.layer.shadowColor = UIColor.lightGray.cgColor +// self.yearlyContainerView.layer.shadowRadius = 4 +// self.yearlyContainerView.layer.shadowOpacity = 1 +// self.yearlyContainerView.layer.shadowOffset = CGSize(width: 0, height: 2) +// self.yearlyContainerView.backgroundColor = .white +// self.yearlyContainerView.layer.cornerRadius = yearlyContainerView.height / 16 +// self.view.addSubview(yearlyContainerView) +// +// let typeLabel = UILabel(leftInset: 0, topInset: 30, width: 335, height: 40) +// typeLabel.text = "Yearly" +// let fontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 34) +// typeLabel.font = UIFont.systemFont(ofSize: fontSize, weight: .bold) +// typeLabel.textColor = Stylesheet.Colors.base +// typeLabel.textAlignment = .center +// +// yearlyContainerView.addSubview(typeLabel) +// +// let selectButton = UIButton(leftInset: 0, topInset: 0, width: 160, height: 40) +// let leftInset = yearlyContainerView.center.x - 20 - (selectButton.width / 2) +// let topInset = typeLabel.frame.maxY + UIView.getValueScaledByScreenHeightFor(baseValue: 15) +// selectButton.x = leftInset +// selectButton.y = topInset +// selectButton.setTitle("$100/Year", for: .normal) +// selectButton.setTitleColor(.white, for: .normal) +// selectButton.backgroundColor = Stylesheet.Colors.secondaryColor +// selectButton.cornerRadius = selectButton.height / 2 +// selectButton.addTarget(self, action: #selector(self.yearlyButtonPressed), for: .touchUpInside) +// yearlyContainerView.addSubview(selectButton) +// } +// +// @objc func yearlyButtonPressed() { +// self.planType = .yearly +// self.handleAddPaymentMethodButtonTapped() +// } +// +// @objc func dismissSelf() { +// self.dismiss(animated: true, completion: nil) +// } +// +// func handleAddPaymentMethodButtonTapped() { +// // @TODO: Remove stripe code later on if we are sure we can't use this +//// // Setup add card view controller +//// let addCardViewController = STPAddCardViewController() +//// addCardViewController.delegate = self +//// +//// // Present add card view controller +//// let navigationController = UINavigationController(rootViewController: addCardViewController) +//// present(navigationController, animated: true) +// +// guard let url = URL(string: "https://www.softwaredaily.com/#/premium") else { +// return +// } +// UIApplication.shared.open(url, options: [:], completionHandler: nil) +// } +// +// // MARK: STPAddCardViewControllerDelegate +// +// func addCardViewControllerDidCancel(_ addCardViewController: STPAddCardViewController) { +// // Dismiss add card view controller +// dismiss(animated: true) +// } +// +// func addCardViewController(_ addCardViewController: STPAddCardViewController, didCreateToken token: STPToken, completion: @escaping STPErrorBlock) { +// self.submitTokenToBackend(stripeToken: token, completion: { (error: Error?) in +// if let error = error { +// // Show error in add card view controller +// Helpers.alertWithMessage(title: L10n.genericError, message: error.localizedDescription) +// completion(error) +// } else { +// // Notify add card view controller that token creation was handled successfully +// self.dismiss(animated: true, completion: { +// Helpers.alertWithMessageCustomAction(title: "🎉 You're subscribed! 🎉", message: "🙌 Thank you! 🙌\n Your support means the world to us!", actionTitle: "Yipee!", completionHandler: { +// // Dismiss purchase view controller +// self.dismiss(animated: true, completion: nil) +// }) +// }) +// completion(nil) +// } +// }) +// } +// +// func submitTokenToBackend(stripeToken: STPToken, completion: @escaping (Error?) -> Void) { +// guard let planType = self.planType else { +// assertionFailure("Plan type not set in VC") +// return +// } +// api.stripeCreateSubscription(token: stripeToken.tokenId, planType: planType) { (error) in +// completion(error) +// } +// } +//} + diff --git a/SEDaily-IOS/RelatedLink.swift b/SEDaily-IOS/RelatedLink.swift new file mode 100644 index 0000000..4183a7b --- /dev/null +++ b/SEDaily-IOS/RelatedLink.swift @@ -0,0 +1,58 @@ +// +// RelatedLink.swift +// SEDaily-IOS +// +// Created by jason on 1/26/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +public struct RelatedLink: BaseFeedItem { + var score: Int = 0 + + var _id: String + var downvoted: Bool? + + var upvoted: Bool? + + let title: String + let url: String + + let postId: String? + let post: PodcastLite? + let image: String? + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + _id = try container.decode(String.self, forKey: ._id) + + if let upvotedResult = try? container.decode(Bool.self, forKey: .upvoted) { + upvoted = upvotedResult + } else { + downvoted = false + } + if let downvotedResult = try? container.decode(Bool.self, forKey: .downvoted) { + downvoted = downvotedResult + } else { + downvoted = false + } + + score = try container.decode(Int.self, forKey: .score) + title = try container.decode(String.self, forKey: .title) + url = try container.decode(String.self, forKey: .url) + + if let value = try? container.decode(PodcastLite.self, forKey: .post) { + post = value + postId = post?._id + } else { + postId = try container.decode(String.self, forKey: .post) + post = nil + } + if let _image = try? container.decode(String.self, forKey: .image) { + image = _image + } else { + image = nil + } + } +} diff --git a/SEDaily-IOS/RelatedLinkWebVC.swift b/SEDaily-IOS/RelatedLinkWebVC.swift new file mode 100644 index 0000000..93ca01c --- /dev/null +++ b/SEDaily-IOS/RelatedLinkWebVC.swift @@ -0,0 +1,79 @@ +// +// RelatedLinkWebVC.swift +// SEDaily-IOS +// +// Created by jason on 5/20/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + + + + +// +// RelatedLInkWebVC.swift +// SEDaily-IOS +// +// Created by jason on 5/18/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import WebKit + +class RelatedLinkWebVC: UIViewController, WKUIDelegate, WKNavigationDelegate { + var webView: WKWebView! + var url:URL? + @IBOutlet weak var simpleSpinner: UIActivityIndicatorView! + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + simpleSpinner.stopAnimating() + webView.isHidden = false + self.view = webView + } + + @IBAction func openInSafariTapped(_ sender: UIButton) { + if let linkUrl = url { + UIApplication.shared.open(linkUrl, options: [:], completionHandler: nil) + Analytics2.relatedLinkSafariOpen(url: linkUrl) + } else { + print("link null") + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewDidLoad() + simpleSpinner.startAnimating() + if let url = url { + let webConfiguration = WKWebViewConfiguration() + webView = WKWebView(frame: .zero, configuration: webConfiguration) + webView.uiDelegate = self + + webView.navigationDelegate = self + webView.isHidden = true + + let myRequest = URLRequest(url: url) + webView.load(myRequest) + Analytics2.relatedLinkViewed(url: url) + Tracker.logRelatedLinkViewedFromFeed(url: url) + } + // Do any additional setup after loading the view. + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destinationViewController. + // Pass the selected object to the new view controller. + } + */ + +} diff --git a/SEDaily-IOS/RelatedLinkWebVC.xib b/SEDaily-IOS/RelatedLinkWebVC.xib new file mode 100644 index 0000000..5b95afd --- /dev/null +++ b/SEDaily-IOS/RelatedLinkWebVC.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SEDaily-IOS/RelatedLinks.storyboard b/SEDaily-IOS/RelatedLinks.storyboard new file mode 100644 index 0000000..d0ff896 --- /dev/null +++ b/SEDaily-IOS/RelatedLinks.storyboard @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SEDaily-IOS/RelatedLinksViewController.swift b/SEDaily-IOS/RelatedLinksViewController.swift new file mode 100644 index 0000000..eed81c4 --- /dev/null +++ b/SEDaily-IOS/RelatedLinksViewController.swift @@ -0,0 +1,254 @@ +// +// RelatedLinksViewController.swift +// SEDaily-IOS +// +// Created by jason on 1/28/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class RelatedLinksViewController: UIViewController { + let activityIndicator: UIActivityIndicatorView = UIActivityIndicatorView() + let networkService: API = API() + var postId = "" + var transcriptURL: String? + let submitButton: UIButton = UIButton() + + var shouldShowAdd = true + + + @IBOutlet weak var noRelatedLinks: UILabel! + var links: [RelatedLink] = [] + @IBOutlet weak var tableView: UITableView! + + var addLinkContainer: UIView! + var addLinkLabel: UILabel! + var shortDescTextField: UITextField! + var linkTextField: UITextField! + var separator: UIView! + + + override func viewDidLoad() { + super.viewDidLoad() + self.title = L10n.relatedLinks + + self.tableView.dataSource = self + self.tableView.delegate = self + + self.tableView.rowHeight = UITableViewAutomaticDimension + self.tableView.estimatedRowHeight = 44 + + setupLayout() + getRelatedLinks() + + // Add activity indicator / spinner + activityIndicator.center = self.view.center + activityIndicator.hidesWhenStopped = true + activityIndicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyle.gray + view.addSubview(activityIndicator) + activityIndicator.startAnimating() + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + func getRelatedLinks() { + networkService.getRelatedLinks(podcastId: self.postId, onSuccess: { [weak self] relatedLinks in + self?.links = relatedLinks + self?.tableView.reloadData() + self?.activityIndicator.stopAnimating() + + //self?.title = "\(relatedLinks.count) \(L10n.relatedLinks)" + if relatedLinks.count == 0 && self?.transcriptURL == nil { + self?.tableView.isHidden = true + } + }, onFailure: { [weak self] _ in + self?.showErrorAlert() + }) + } + + @objc func submitTapped() { + guard let title: String = shortDescTextField.text else { return } + guard let url: String = linkTextField.text else { return } + guard !url.isEmpty, !title.isEmpty else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.fieldEmpty, completionHandler: nil) + return + } + submitButton.isEnabled = false + networkService.addRelatedLink(podcastId: self.postId, title: title, url: url, onSuccess: { [weak self] in + self?.getRelatedLinks() + self?.cleanTextFields() + self?.submitButton.isEnabled = true + }, onFailure: { [weak self] _ in + self?.submitButton.isEnabled = true + self?.showErrorAlert() + }) + } + +} + +extension RelatedLinksViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard self.transcriptURL != nil else { return self.links.count } + return self.links.count + 1 + } + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) + if transcriptURL != nil { + switch indexPath.row { + case 0: + cell.textLabel?.text = L10n.transcript + default: + cell.textLabel?.text = self.links[indexPath.row-1].title + } + } else { + cell.textLabel?.text = self.links[indexPath.row].title + } + cell.textLabel?.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + cell.textLabel?.textColor = Stylesheet.Colors.dark + cell.selectionStyle = .none + cell.accessoryType = .disclosureIndicator + + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // Add http:// prefix to url if it doesn't exist so it can be opened: + // Could be moved to themodel + var urlString: String + if transcriptURL != nil { + switch indexPath.row { + case 0: + urlString = transcriptURL! + default: + urlString = links[indexPath.row-1].url + } + } else { + urlString = links[indexPath.row].url + } + let urlPrefix = urlString.prefix(4) + if urlPrefix != "http" { + // Defaulting to http: + if urlPrefix.prefix(3) == "://" { + urlString = "http\(urlString)" + } else { + urlString = "http://\(urlString)" + } + } + + // Open the link: + if let linkUrl = URL(string: urlString) { + UIApplication.shared.open(linkUrl, options: [:], completionHandler: nil) + } else { + } + } +} + +extension RelatedLinksViewController { + +} + +extension RelatedLinksViewController { + private func setupLayout() { + func setupAddLinkContainer() { + + addLinkContainer = UIView(frame: CGRect(x: 0.0, y: 0.0, width: UIScreen.main.bounds.width, height: 200.0)) + addLinkContainer.backgroundColor = .white + + addLinkLabel = UILabel() + addLinkLabel.text = L10n.newLink + addLinkLabel.baselineAdjustment = .alignCenters + addLinkLabel.numberOfLines = 0 + addLinkLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + addLinkLabel.textColor = Stylesheet.Colors.dark + + tableView.tableFooterView = UserManager.sharedInstance.isCurrentUserLoggedIn() ? addLinkContainer : UIView() + + separator = UIView() + separator.backgroundColor = Stylesheet.Colors.light + + shortDescTextField = UITextField() + shortDescTextField.placeholder = L10n.shortTitle + shortDescTextField.applyStyle() + + linkTextField = UITextField() + linkTextField.placeholder = L10n.addLink + linkTextField.applyStyle() + + submitButton.setTitle(L10n.submit, for: .normal) + submitButton.titleLabel?.font = UIFont(name: "OpenSans-Semibold", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + submitButton.setTitleColor(Stylesheet.Colors.base, for: .normal) + + addLinkContainer.addSubviews([separator, addLinkLabel, shortDescTextField, linkTextField, submitButton]) + } + + func setupConstraints() { + + separator.snp.makeConstraints { (make) -> Void in + make.left.right.top.equalToSuperview() + make.height.equalTo(5.0) + } + + addLinkLabel.snp.makeConstraints { (make) in + make.top.equalTo(separator.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + } + + shortDescTextField.snp.makeConstraints { (make) in + make.top.equalTo(addLinkLabel.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.height.equalTo(40) + + } + + linkTextField.snp.makeConstraints { (make) in + make.top.equalTo(shortDescTextField.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + make.height.equalTo(40) + + } + + submitButton.snp.makeConstraints { (make) in + make.top.equalTo(linkTextField.snp_bottom).offset(UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + make.centerX.equalToSuperview() + } + } + setupAddLinkContainer() + setupConstraints() + submitButton.addTarget(self, action: #selector(RelatedLinksViewController.submitTapped), for: .touchUpInside) + } + + private func cleanTextFields() { + shortDescTextField.text = nil + linkTextField.text = nil + } +} + +extension RelatedLinksViewController { + private func showErrorAlert() { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.somethingWentWrong, completionHandler: nil) + } + private func showEmptyFieldsErrorAlert() { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.fieldEmpty, completionHandler: nil) + } +} + +private extension UITextField { + func applyStyle() { + self.borderStyle = .roundedRect + self.borderColor = Stylesheet.Colors.light + self.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 13)) + } +} diff --git a/SEDaily-IOS/RemoteCommandDataSource.swift b/SEDaily-IOS/RemoteCommandDataSource.swift new file mode 100644 index 0000000..5031bf5 --- /dev/null +++ b/SEDaily-IOS/RemoteCommandDataSource.swift @@ -0,0 +1,139 @@ +// +// RemoteCommandDataSource.swift +// MPRemoteCommandSample +// +// Created by Bilal S. Sayed Ahmad on 10/17/16. +// +// +import Foundation + +class RemoteCommandDataSource: NSObject { + + let remoteCommandManager: RemoteCommandManager + + init(remoteCommandManager: RemoteCommandManager) { + self.remoteCommandManager = remoteCommandManager + } + + /// Enumeration of the different sections of the UITableView. + private enum CommandSection: Int { + case trackChanging, skipInterval, seek, feedback + + func sectionTitle() -> String { + switch self { + case .trackChanging: return "Track Changing Commands" + case .skipInterval: return "Skip Interval Commands" + case .seek: return "Seek Commands" + case .feedback: return "Feedback Commands" + } + } + } + + /// Enumeration of the various commands supported by `MPRemoteCommandCenter`. + private enum Command { + case nextTrack, previousTrack, skipForward, skipBackward, seekForward, seekBackward, changePlaybackPosition, like, dislike, bookmark + + init?(_ section: Int, row: Int) { + guard let section = CommandSection(rawValue: section) else { return nil } + + switch section { + case .trackChanging: + if row == 0 { + self = .nextTrack + } + else { + self = .previousTrack + } + case .skipInterval: + if row == 0 { + self = .skipForward + } + else { + self = .skipBackward + } + case .seek: + if row == 0 { + self = .seekForward + } + else if row == 1 { + self = .seekBackward + } + else { + self = .changePlaybackPosition + } + case .feedback: + if row == 0 { + self = .like + } + else if row == 1 { + self = .dislike + } + else { + self = .bookmark + } + } + } + + func commandTitle() -> String { + switch self { + case .nextTrack: return "Next Track Command" + case .previousTrack: return "Previous Track Command" + case .skipForward: return "Skip Forward Command" + case .skipBackward: return "Skip Backward Command" + case .seekForward: return "Seek Forward Command" + case .seekBackward: return "Seek Backward Command" + case .changePlaybackPosition: return "Change Playback Position Command" + case .like: return "Like Command" + case .dislike: return "Dislike Command" + case .bookmark: return "Bookmark Command" + } + } + } + + func numberOfRemoteCommandSections() -> Int { + #if os(iOS) + return 4 + #else + return 3 + #endif + } + + func titleForSection(_ section: Int) -> String { + guard let commandSection = CommandSection(rawValue: section) else { return "Invalid Section" } + + return commandSection.sectionTitle() + } + + func titleStringForCommand(at section: Int, row: Int) -> String { + guard let remoteCommand = Command(section, row: row) else { return "Invalid Command" } + + return remoteCommand.commandTitle() + } + + func numberOfItemsInSection(_ section: Int) -> Int { + switch section { + case 0: return 2 + case 1: return 2 + case 2: return 3 + case 3: return 3 + default: return 0 + } + } + + func toggleCommandHandler(with section: Int, row: Int, enable: Bool) { + guard let remoteCommand = Command(section, row: row) else { return } + + switch remoteCommand { + case .nextTrack: remoteCommandManager.toggleNextTrackCommand(enable) + case .previousTrack: remoteCommandManager.togglePreviousTrackCommand(enable) + case .skipForward: remoteCommandManager.toggleSkipForwardCommand(enable, interval: 15) + case .skipBackward: remoteCommandManager.toggleSkipBackwardCommand(enable, interval: 20) + case .seekForward: remoteCommandManager.toggleSeekForwardCommand(enable) + case .seekBackward: remoteCommandManager.toggleSeekBackwardCommand(enable) + case .changePlaybackPosition: remoteCommandManager.toggleChangePlaybackPositionCommand(enable) + case .like: remoteCommandManager.toggleLikeCommand(enable) + case .dislike: remoteCommandManager.toggleDislikeCommand(enable) + case .bookmark: remoteCommandManager.toggleBookmarkCommand(enable) + } + } +} diff --git a/SEDaily-IOS/RemoteCommandManager.swift b/SEDaily-IOS/RemoteCommandManager.swift new file mode 100644 index 0000000..9933b89 --- /dev/null +++ b/SEDaily-IOS/RemoteCommandManager.swift @@ -0,0 +1,317 @@ +/* + Copyright (C) 2016 Apple Inc. All Rights Reserved. + See LICENSE.txt for this sample’s licensing information + + Abstract: + `RemoteCommandManager` contains all the APIs calls to MPRemoteCommandCenter to enable and disable various remote control events. + */ + +import Foundation +import MediaPlayer + +public class RemoteCommandManager: NSObject { + + // MARK: Properties + + /// Reference of `MPRemoteCommandCenter` used to configure and setup remote control events in the application. + fileprivate let remoteCommandCenter = MPRemoteCommandCenter.shared() + + /// The instance of `AssetPlaybackManager` to use for responding to remote command events. + let assetPlaybackManager: AssetPlayer + + // MARK: Initialization. + + public init(assetPlaybackManager: AssetPlayer) { + self.assetPlaybackManager = assetPlaybackManager + } + + deinit { + + #if os(tvOS) + activatePlaybackCommands(false) + #endif + + activatePlaybackCommands(false) + toggleNextTrackCommand(false) + togglePreviousTrackCommand(false) + toggleSkipForwardCommand(false) + toggleSkipBackwardCommand(false) + toggleSeekForwardCommand(false) + toggleSeekBackwardCommand(false) + toggleChangePlaybackPositionCommand(false) + toggleLikeCommand(false) + toggleDislikeCommand(false) + toggleBookmarkCommand(false) + } + + // MARK: MPRemoteCommand Activation/Deactivation Methods + + #if os(tvOS) + public func activateRemoteCommands(_ enable: Bool) { + activatePlaybackCommands(enable) + + // To support Siri's "What did they say?" command you have to support the appropriate skip commands. See the README for more information. + toggleSkipForwardCommand(enable, interval: 15) + toggleSkipBackwardCommand(enable, interval: 20) + } + #endif + + public func activatePlaybackCommands(_ enable: Bool) { + if enable { + remoteCommandCenter.playCommand.addTarget(self, action: #selector(RemoteCommandManager.handlePlayCommandEvent(_:))) + remoteCommandCenter.pauseCommand.addTarget(self, action: #selector(RemoteCommandManager.handlePauseCommandEvent(_:))) + remoteCommandCenter.stopCommand.addTarget(self, action: #selector(RemoteCommandManager.handleStopCommandEvent(_:))) + remoteCommandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(RemoteCommandManager.handleTogglePlayPauseCommandEvent(_:))) + + } + else { + remoteCommandCenter.playCommand.removeTarget(self, action: #selector(RemoteCommandManager.handlePlayCommandEvent(_:))) + remoteCommandCenter.pauseCommand.removeTarget(self, action: #selector(RemoteCommandManager.handlePauseCommandEvent(_:))) + remoteCommandCenter.stopCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleStopCommandEvent(_:))) + remoteCommandCenter.togglePlayPauseCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleTogglePlayPauseCommandEvent(_:))) + } + + remoteCommandCenter.playCommand.isEnabled = enable + remoteCommandCenter.pauseCommand.isEnabled = enable + remoteCommandCenter.stopCommand.isEnabled = enable + remoteCommandCenter.togglePlayPauseCommand.isEnabled = enable + } + + public func toggleNextTrackCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.nextTrackCommand.addTarget(self, action: #selector(RemoteCommandManager.handleNextTrackCommandEvent(_:))) + } + else { + remoteCommandCenter.nextTrackCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleNextTrackCommandEvent(_:))) + } + + remoteCommandCenter.nextTrackCommand.isEnabled = enable + } + + public func togglePreviousTrackCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.previousTrackCommand.addTarget(self, action: #selector(RemoteCommandManager.handlePreviousTrackCommandEvent(event:))) + } + else { + remoteCommandCenter.previousTrackCommand.removeTarget(self, action: #selector(RemoteCommandManager.handlePreviousTrackCommandEvent(event:))) + } + + remoteCommandCenter.previousTrackCommand.isEnabled = enable + } + + public func toggleSkipForwardCommand(_ enable: Bool, interval: Int = 0) { + if enable { + remoteCommandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: interval)] + remoteCommandCenter.skipForwardCommand.addTarget(self, action: #selector(RemoteCommandManager.handleSkipForwardCommandEvent(event:))) + } + else { + remoteCommandCenter.skipForwardCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleSkipForwardCommandEvent(event:))) + } + + remoteCommandCenter.skipForwardCommand.isEnabled = enable + } + + public func toggleSkipBackwardCommand(_ enable: Bool, interval: Int = 0) { + if enable { + remoteCommandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: interval)] + remoteCommandCenter.skipBackwardCommand.addTarget(self, action: #selector(RemoteCommandManager.handleSkipBackwardCommandEvent(event:))) + } + else { + remoteCommandCenter.skipBackwardCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleSkipBackwardCommandEvent(event:))) + } + + remoteCommandCenter.skipBackwardCommand.isEnabled = enable + } + + public func toggleSeekForwardCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.seekForwardCommand.addTarget(self, action: #selector(RemoteCommandManager.handleSeekForwardCommandEvent(event:))) + } + else { + remoteCommandCenter.seekForwardCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleSeekForwardCommandEvent(event:))) + } + + remoteCommandCenter.seekForwardCommand.isEnabled = enable + } + + public func toggleSeekBackwardCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.seekBackwardCommand.addTarget(self, action: #selector(RemoteCommandManager.handleSeekBackwardCommandEvent(event:))) + } + else { + remoteCommandCenter.seekBackwardCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleSeekBackwardCommandEvent(event:))) + } + + remoteCommandCenter.seekBackwardCommand.isEnabled = enable + } + + public func toggleChangePlaybackPositionCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.changePlaybackPositionCommand.addTarget(self, action: #selector(RemoteCommandManager.handleChangePlaybackPositionCommandEvent(event:))) + } + else { + remoteCommandCenter.changePlaybackPositionCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleChangePlaybackPositionCommandEvent(event:))) + } + + + remoteCommandCenter.changePlaybackPositionCommand.isEnabled = enable + } + + public func toggleLikeCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.likeCommand.addTarget(self, action: #selector(RemoteCommandManager.handleLikeCommandEvent(event:))) + } + else { + remoteCommandCenter.likeCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleLikeCommandEvent(event:))) + } + + remoteCommandCenter.likeCommand.isEnabled = enable + } + + public func toggleDislikeCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.dislikeCommand.addTarget(self, action: #selector(RemoteCommandManager.handleDislikeCommandEvent(event:))) + } + else { + remoteCommandCenter.dislikeCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleDislikeCommandEvent(event:))) + } + + remoteCommandCenter.dislikeCommand.isEnabled = enable + } + + public func toggleBookmarkCommand(_ enable: Bool) { + if enable { + remoteCommandCenter.bookmarkCommand.addTarget(self, action: #selector(RemoteCommandManager.handleBookmarkCommandEvent(event:))) + } + else { + remoteCommandCenter.bookmarkCommand.removeTarget(self, action: #selector(RemoteCommandManager.handleBookmarkCommandEvent(event:))) + } + + remoteCommandCenter.bookmarkCommand.isEnabled = enable + } + + // MARK: MPRemoteCommand handler methods. + + // MARK: Playback Command Handlers + @objc func handlePauseCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.pause() + + return .success + } + + @objc func handlePlayCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.play() + return .success + } + + @objc func handleStopCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.stop() + + return .success + } + + @objc func handleTogglePlayPauseCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.togglePlayPause() + + return .success + } + + // MARK: Track Changing Command Handlers + @objc func handleNextTrackCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + if assetPlaybackManager.asset != nil { + assetPlaybackManager.nextTrack() + + return .success + } + else { + return .noSuchContent + } + } + + @objc func handlePreviousTrackCommandEvent(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + if assetPlaybackManager.asset != nil { + assetPlaybackManager.previousTrack() + + return .success + } + else { + return .noSuchContent + } + } + + // MARK: Skip Interval Command Handlers + @objc func handleSkipForwardCommandEvent(event: MPSkipIntervalCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.skipForward(event.interval) + + return .success + } + + @objc func handleSkipBackwardCommandEvent(event: MPSkipIntervalCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.skipBackward(event.interval) + + return .success + } + + // MARK: Seek Command Handlers + @objc func handleSeekForwardCommandEvent(event: MPSeekCommandEvent) -> MPRemoteCommandHandlerStatus { + + switch event.type { + case .beginSeeking: assetPlaybackManager.beginFastForward() + case .endSeeking: assetPlaybackManager.endRewindFastForward() + } + return .success + } + + @objc func handleSeekBackwardCommandEvent(event: MPSeekCommandEvent) -> MPRemoteCommandHandlerStatus { + switch event.type { + case .beginSeeking: assetPlaybackManager.beginRewind() + case .endSeeking: assetPlaybackManager.endRewindFastForward() + } + return .success + } + + @objc func handleChangePlaybackPositionCommandEvent(event: MPChangePlaybackPositionCommandEvent) -> MPRemoteCommandHandlerStatus { + assetPlaybackManager.seekTo(interval: event.positionTime) + + return .success + } + + // MARK: Feedback Command Handlers + @objc func handleLikeCommandEvent(event: MPFeedbackCommandEvent) -> MPRemoteCommandHandlerStatus { + + if assetPlaybackManager.asset != nil { + print("Did recieve likeCommand for \(String(describing: assetPlaybackManager.asset?.assetName))") + return .success + } + else { + return .noSuchContent + } + } + + @objc func handleDislikeCommandEvent(event: MPFeedbackCommandEvent) -> MPRemoteCommandHandlerStatus { + + if assetPlaybackManager.asset != nil { + print("Did recieve dislikeCommand for \(String(describing: assetPlaybackManager.asset?.assetName))") + return .success + } + else { + return .noSuchContent + } + } + + @objc func handleBookmarkCommandEvent(event: MPFeedbackCommandEvent) -> MPRemoteCommandHandlerStatus { + + if assetPlaybackManager.asset != nil { + print("Did recieve bookmarkCommand for \(String(describing: assetPlaybackManager.asset?.assetName))") + return .success + } + else { + return .noSuchContent + } + } +} + +// MARK: Convienence Category to make it easier to expose different types of remote command groups as the UITableViewDataSource in RemoteCommandListTableViewController. +extension RemoteCommandManager { + +} diff --git a/SEDaily-IOS/Roboto-Bold.ttf b/SEDaily-IOS/Roboto-Bold.ttf new file mode 100755 index 0000000..d3f01ad Binary files /dev/null and b/SEDaily-IOS/Roboto-Bold.ttf differ diff --git a/SEDaily-IOS/Roboto-Light.ttf b/SEDaily-IOS/Roboto-Light.ttf new file mode 100755 index 0000000..219063a Binary files /dev/null and b/SEDaily-IOS/Roboto-Light.ttf differ diff --git a/SEDaily-IOS/Roboto-Regular.ttf b/SEDaily-IOS/Roboto-Regular.ttf new file mode 100755 index 0000000..2c97eea Binary files /dev/null and b/SEDaily-IOS/Roboto-Regular.ttf differ diff --git a/SEDaily-IOS/RootViewController.swift b/SEDaily-IOS/RootViewController.swift new file mode 100644 index 0000000..c5ec5cf --- /dev/null +++ b/SEDaily-IOS/RootViewController.swift @@ -0,0 +1,190 @@ +// +// RootViewController.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/26/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import SnapKit + +class RootViewController: UIViewController, MainCoordinated, Stateful { + + var stateController: StateController? + + private var containerView = UIView() + private var overlayContainerView = UIView() + private var tabController: MainTabBarController = MainTabBarController() + var overlayController: OverlayViewController = OverlayViewController() + + weak var mainCoordinator: MainFlowCoordinator? + + override func viewDidLoad() { + super.viewDidLoad() + overlayController.delegate = self + configure() + getRecentlyListenedPodcast() + } + + private func getRecentlyListenedPodcast() { + + guard let id = PlayProgressModelController.getRecentlyListenedEpisodeId() else { return } + let repository = PodcastRepository() + repository.retrieveRecentlyListened(podcastId: id, + onSuccess: { [weak self](podcasts) in + podcasts.forEach({ podcast in + guard let strongSelf = self else { return } + let viewModel = PodcastViewModel(podcast: podcast) + strongSelf.overlayController.viewModel = viewModel + strongSelf.overlayController.expanded = false + strongSelf.overlayContainerView.isHidden = false + strongSelf.stateController?.isOverlayShowing = true + strongSelf.stateController?.setCurrentlyPlaying(id: viewModel._id) + })}, + onFailure: { _ in }) + } + + + private func toggleState() { + overlayContainerView.snp.remakeConstraints { (make) -> Void in + self.overlayController.expanded ? make.height.equalTo(80) : make.top.equalToSuperview() + self.overlayController.expanded ? make.bottom.equalTo(tabController.tabBar.snp.top) : make.bottom.equalToSuperview() + make.right.equalToSuperview() + make.left.equalToSuperview() + } + + UIView.animate(withDuration: 0.2, animations: { + self.view.layoutIfNeeded() + }) + + overlayController.expanded = !overlayController.expanded + } + + @objc func didTap() { + expand() + } + + @objc func didSwipeDown() { + collapse() + } + + private func collapse() { + if overlayController.expanded { + toggleState() + } + } + + private func expand() { + if !overlayController.expanded { + toggleState() + } + } +} + +extension RootViewController { + + private func addOverlayViewController(viewModel: PodcastViewModel) { + view.addSubview(overlayContainerView) + mainCoordinator?.configure(viewController: overlayController) + add(asChildViewController: overlayController, container: overlayContainerView) + + overlayContainerView.isHidden = true + stateController?.isOverlayShowing = false + + let tap = UITapGestureRecognizer(target: self, action: #selector(RootViewController.didTap)) + overlayContainerView.addGestureRecognizer(tap) + overlayContainerView.isUserInteractionEnabled = true + + let swipeDown = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeDown)) + swipeDown.direction = .down + self.overlayContainerView.addGestureRecognizer(swipeDown) + + overlayContainerView.snp.makeConstraints { (make) -> Void in + make.height.equalTo(80.0) + make.right.equalToSuperview() + make.left.equalToSuperview() + make.bottom.equalTo(tabController.tabBar.snp.top) + } + + overlayController.viewModel = viewModel + } + + private func add(asChildViewController viewController: UIViewController, container: UIView) { + // Add Child View Controller + addChildViewController(viewController) + container.addSubview(viewController.view) + let height = container.frame.height + let width = container.frame.width + viewController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) + viewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + viewController.didMove(toParentViewController: self) + } +} + +extension RootViewController { + + private func configure() { + self.view.backgroundColor = .blue + self.view.addSubview(containerView) + self.add(asChildViewController: tabController, container: containerView) + mainCoordinator?.configure(viewController: tabController) + addOverlayViewController(viewModel: PodcastViewModel()) + + containerView.snp.makeConstraints { (make) -> Void in + make.top.equalToSuperview() + make.right.equalToSuperview() + make.left.equalToSuperview() + make.bottom.equalToSuperview() + } + } +} + +extension RootViewController: OverlayControllerDelegate { + + func didSelectInfo(viewModel: PodcastViewModel) { + guard let viewController = tabController.selectedViewController as? UINavigationController else { return } + mainCoordinator?.viewController(viewController, with: viewModel) + collapse() + } + + func didTapCollapse() { + collapse() + } + + func didTapStop() { + overlayContainerView.snp.remakeConstraints { (make) -> Void in + make.height.equalTo(0) + make.bottom.equalTo(tabController.tabBar.snp.top) + make.right.equalToSuperview() + make.left.equalToSuperview() + } + + + UIView.animate(withDuration: 0.1, animations: { + self.view.layoutIfNeeded() + }, completion: { _ in + self.overlayContainerView.isHidden = true + }) + + stateController?.isOverlayShowing = false + PlayProgressModelController.cleanRecentlyListenedEpisodeId() + } + + func didTapPlay() { + + overlayContainerView.snp.remakeConstraints { (make) -> Void in + make.height.equalTo(80) + make.bottom.equalTo(tabController.tabBar.snp.top) + make.right.equalToSuperview() + make.left.equalToSuperview() + } + overlayController.expanded = false + self.overlayContainerView.isHidden = false + UIView.animate(withDuration: 0.1, animations: { + self.view.layoutIfNeeded() + }, completion: { _ in }) + + stateController?.isOverlayShowing = true + } +} diff --git a/SEDaily-IOS/SearchCollectionViewController.swift b/SEDaily-IOS/SearchCollectionViewController.swift new file mode 100644 index 0000000..7689100 --- /dev/null +++ b/SEDaily-IOS/SearchCollectionViewController.swift @@ -0,0 +1,281 @@ +// +// SearchTableViewController.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 9/7/17. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import UIKit +import KTResponsiveUI + +import StatefulViewController + +private let reuseIdentifier = "Cell" + +class SearchCollectionViewController: UICollectionViewController, StatefulViewController, MainCoordinated { + + var mainCoordinator: MainFlowCoordinator? + + private let podcastViewModelController = PodcastViewModelController() + + private var progressController = PlayProgressModelController() + + var loading = false + private let pageSize = 10 + private let preloadMargin = 5 + private var lastLoadedPage = 0 + var errorChecks = 0 + let maximumErrorChecks = 5 + + private let searchController = UISearchController(searchResultsController: nil) + private var searchText: String { + return searchController.searchBar.text ?? "" + } + + override init(collectionViewLayout layout: UICollectionViewLayout) { + super.init(collectionViewLayout: layout) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.collectionView?.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + //hardcoded height + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light + + // User Login observer + NotificationCenter.default.addObserver(self, selector: #selector(self.loginObserver), name: .loginChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(self.onDidReceiveData(_:)), + name: .viewModelUpdated, + object: nil) + + + searchController.searchBar.delegate = self + searchController.obscuresBackgroundDuringPresentation = false + searchController.searchBar.layer.borderWidth = 1 + searchController.searchBar.layer.borderColor = Stylesheet.Colors.light.cgColor + searchController.searchBar.tintColor = Stylesheet.Colors.base + searchController.searchBar.barTintColor = Stylesheet.Colors.light + + definesPresentationContext = true + + //self.extendedLayoutIncludesOpaqueBars = true + //self.edgesForExtendedLayout = .bottom + //searchController.hidesNavigationBarDuringPresentation = false + + + searchController.dimsBackgroundDuringPresentation = false + + + self.collectionView?.contentInset = UIEdgeInsetsMake(searchController.searchBar.frame.height - UIView.getValueScaledByScreenHeightFor(baseValue: 10), 0, 0, 0) + self.view.addSubview(searchController.searchBar) + self.title = L10n.search + + self.loadingView = StateView( + frame: CGRect.zero, + text: L10n.fetchingSearch, + showLoadingIndicator: true, + showRefreshButton: false, + delegate: nil) + self.loadingView?.isUserInteractionEnabled = false + + self.emptyView = StateView( + frame: CGRect.zero, + text: L10n.emptySearch, + showLoadingIndicator: false, + showRefreshButton: false, + delegate: nil) + self.emptyView?.isUserInteractionEnabled = false + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.searchController.searchBar.isHidden = false + self.setupInitialViewState() + progressController.retrieve() + self.collectionView?.reloadData() + } + + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.searchController.searchBar.isHidden = true + } + + deinit { + // perform the deinitialization + NotificationCenter.default.removeObserver(self) + } + + func hasContent() -> Bool { + return podcastViewModelController.viewModelsCount > 0 + } + + // MARK: UICollectionViewDataSource + + override func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return podcastViewModelController.viewModelsCount + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + + // Configure the cell + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + + let upvoteService = UpvoteService(podcastViewModel: viewModel) + let bookmarkService = BookmarkService(podcastViewModel: viewModel) + let downloadService = DownloadService(podcastViewModel: viewModel) + + cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) + + + cell.viewModel = viewModel + cell.upvoteService = upvoteService + cell.bookmarkService = bookmarkService + + + cell.commentShowCallback = { [weak self] in + self?.commentsButtonPressed(viewModel) + + } + + if let lastIndexPath = self.collectionView?.indexPathForLastItem { + if let lastItem = podcastViewModelController.viewModel(at: lastIndexPath.row) { + self.checkPage(currentIndexPath: indexPath, + lastIndexPath: lastIndexPath, + lastIdentifier: lastItem.uploadDateiso8601) + } + } + } + return cell + } + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { + let vc = EpisodeViewController() + mainCoordinator?.configure(viewController: vc) + vc.viewModel = viewModel + self.navigationController?.pushViewController(vc, animated: true) + + } + } + + @objc func loginObserver() { + self.podcastViewModelController.clearViewModels() + DispatchQueue.main.async { + self.collectionView?.reloadData() + } + self.getData(lastIdentifier: "", nextPage: 0, firstSearch: false) + } +} + + +extension SearchCollectionViewController { + func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { + let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 + let preloadIndex = nextPage * self.pageSize - self.preloadMargin + + if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { + self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage, firstSearch: false) + } + } + + func getData(lastIdentifier: String, nextPage: Int, firstSearch: Bool) { + self.startLoading() + guard self.loading == false else { return } + self.loading = true + podcastViewModelController.fetchSearchData( + searchTerm: self.searchText.lowercased(), + createdAtBefore: lastIdentifier, + firstSearch: firstSearch, + onSuccess: { [weak self] in + self?.errorChecks = 0 + self?.loading = false + self?.endLoading() + self?.lastLoadedPage = nextPage + DispatchQueue.main.async { + self?.collectionView?.reloadData() + } }, + onFailure: { [weak self] (apiError) in + self?.endLoading() + self?.loading = false + self?.errorChecks += 1 + log.error(apiError ?? "") + guard let strongSelf = self else { return } + guard strongSelf.errorChecks <= strongSelf.maximumErrorChecks else { return } }) + } +} + +extension SearchCollectionViewController { + func filterContentForSearchText(_ searchText: String) { + guard !searchBarIsEmpty() else { return } + self.getData(lastIdentifier: "", nextPage: 0, firstSearch: true) + } + + func searchBarIsEmpty() -> Bool { + return searchController.searchBar.text?.isEmpty ?? true + } + + func isFiltering() -> Bool { + let searchBarScopeIsFiltering = searchController.searchBar.selectedScopeButtonIndex != 0 + return searchController.isActive && (!searchBarIsEmpty() || searchBarScopeIsFiltering) + } +} + +extension SearchCollectionViewController: UISearchBarDelegate { + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + filterContentForSearchText(searchController.searchBar.text!) + } +} + + + +extension SearchCollectionViewController { + + func commentsButtonPressed(_ viewModel: PodcastViewModel) { + Analytics2.podcastCommentsViewed(podcastId: viewModel._id) + let commentsViewController: CommentsViewController = CommentsViewController() + if let thread = viewModel.thread { + commentsViewController.rootEntityId = thread._id + self.navigationController?.pushViewController(commentsViewController, animated: true) + } + } +} + +extension SearchCollectionViewController { + @objc func onDidReceiveData(_ notification: Notification) { + if let data = notification.userInfo as? [String: PodcastViewModel] { + for (_, viewModel) in data { + viewModelDidChange(viewModel: viewModel) + } + } + } +} + +extension SearchCollectionViewController { + private func viewModelDidChange(viewModel: PodcastViewModel) { + self.podcastViewModelController.update(with: viewModel) + } +} diff --git a/SEDaily-IOS/SearchTableViewController.swift b/SEDaily-IOS/SearchTableViewController.swift deleted file mode 100644 index 895e76d..0000000 --- a/SEDaily-IOS/SearchTableViewController.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// SearchTableViewController.swift -// SEDaily-IOS -// -// Created by Craig Holliday on 9/7/17. -// Copyright © 2017 Koala Tea. All rights reserved. -// - -import UIKit -import KTResponsiveUI - -private let reuseIdentifier = "reuseIdentifier" - -class SearchTableViewController: UITableViewController { - - // ViewModelController - private let podcastViewModelController = PodcastViewModelController() - - lazy var footerView: UIActivityIndicatorView = { - let footerView = UIActivityIndicatorView(height: 44) - footerView.width = self.tableView.width - footerView.activityIndicatorViewStyle = .gray - return footerView - }() - - // MARK: - Paging - let pageSize = 10 - let preloadMargin = 5 - var lastLoadedPage = 0 - var loading = false - - let searchController = UISearchController(searchResultsController: nil) - var searchText: String { - get { - return searchController.searchBar.text ?? "" - } - } - - var isLoading = false { - didSet { - switch isLoading { - case true: - footerView.startAnimating() - case false: - footerView.stopAnimating() - } - } - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.tableView.register(PodcastTableViewCell.self, forCellReuseIdentifier: reuseIdentifier) - - searchController.searchBar.delegate = self - searchController.searchBar.tintColor = Stylesheet.Colors.base - searchController.dimsBackgroundDuringPresentation = false - searchController.hidesNavigationBarDuringPresentation = false -// definesPresentationContext = true - - self.tableView.tableHeaderView = searchController.searchBar - - tableView.separatorStyle = .singleLine - tableView.rowHeight = UIView.getValueScaledByScreenHeightFor(baseValue: 75) - - self.tableView.tableFooterView = footerView - - self.title = "Search" - } - - override func viewWillAppear(_ animated: Bool) { - self.searchController.searchBar.isHidden = false - } - - override func viewWillDisappear(_ animated: Bool) { - self.searchController.searchBar.isHidden = true - } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - // Dispose of any resources that can be recreated. - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - // #warning Incomplete implementation, return the number of sections - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return podcastViewModelController.viewModelsCount - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as! PodcastTableViewCell - - if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { - cell.viewModel = viewModel - if let lastIndexPath = self.tableView?.indexPathForLastRow { - if let lastItem = podcastViewModelController.viewModel(at: lastIndexPath.row) { - self.checkPage(currentIndexPath: indexPath, - lastIndexPath: lastIndexPath, - lastIdentifier: lastItem.uploadDateiso8601) - } - } - } - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let viewModel = podcastViewModelController.viewModel(at: indexPath.row) { - let vc = PodcastDetailViewController() - vc.model = viewModel - self.navigationController?.pushViewController(vc, animated: true) - } - } -} - -extension SearchTableViewController { - // MARK: Data/Paging - func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { - let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 - let preloadIndex = nextPage * self.pageSize - self.preloadMargin - - if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { - self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage, firstSearch: false) - } - } - - func getData(lastIdentifier: String, nextPage: Int, firstSearch: Bool) { - guard self.loading == false else { return } - self.loading = true - podcastViewModelController.fetchSearchData(searchTerm: self.searchText.lowercased(), createdAtBefore: lastIdentifier, firstSearch: firstSearch, onSucces: { - self.loading = false - self.lastLoadedPage = nextPage - DispatchQueue.main.async { - self.tableView.reloadData() - } - }) { (apiError) in - self.loading = false - log.error(apiError) - } - } -} - -extension SearchTableViewController { - // MARK: - Private instance methods - - func filterContentForSearchText(_ searchText: String) { - guard !searchBarIsEmpty() else { return } - self.getData(lastIdentifier: "", nextPage: 0, firstSearch: true) - } - - func searchBarIsEmpty() -> Bool { - return searchController.searchBar.text?.isEmpty ?? true - } - - func isFiltering() -> Bool { - let searchBarScopeIsFiltering = searchController.searchBar.selectedScopeButtonIndex != 0 - return searchController.isActive && (!searchBarIsEmpty() || searchBarScopeIsFiltering) - } -} - -extension SearchTableViewController: UISearchBarDelegate { - // MARK: - UISearchBar Delegate - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - filterContentForSearchText(searchController.searchBar.text!) - } -} diff --git a/SEDaily-IOS/SeparatorCell.swift b/SEDaily-IOS/SeparatorCell.swift new file mode 100644 index 0000000..74712b3 --- /dev/null +++ b/SEDaily-IOS/SeparatorCell.swift @@ -0,0 +1,37 @@ +// +// SeparatorCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/7/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + + +import UIKit +import Reusable + +class SeparatorCell: UITableViewCell, Reusable { + private var separator: UIView = UIView() + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + contentView.addSubview(separator) + setupLayout() + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } +} + +extension SeparatorCell { + private func setupLayout() { + + separator.backgroundColor = Stylesheet.Colors.light + + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.top.equalToSuperview() + make.height.equalTo(5.0) + } + } +} diff --git a/SEDaily-IOS/SettingsCell.swift b/SEDaily-IOS/SettingsCell.swift new file mode 100644 index 0000000..8668bf9 --- /dev/null +++ b/SEDaily-IOS/SettingsCell.swift @@ -0,0 +1,52 @@ +// +// NotificationTableViewCell.swift +// SEDaily-IOS +// +// Created by Keith Holliday on 4/2/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable + +class SettingsCell: UITableViewCell, Reusable { + public var cellLabel: UILabel! + var separator: UIView! + + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + cellLabel = UILabel() + cellLabel.baselineAdjustment = .alignCenters + cellLabel.numberOfLines = 0 + cellLabel.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + cellLabel.textColor = Stylesheet.Colors.dark + + + self.contentView.addSubview(cellLabel) + setupSeparator() + + cellLabel.snp.makeConstraints { (make) in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + } + + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(2.0) + } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + func setupSeparator() { + separator = UIView() + contentView.addSubview(separator) + separator.backgroundColor = Stylesheet.Colors.light + } +} diff --git a/SEDaily-IOS/SkeletonCollectionView.swift b/SEDaily-IOS/SkeletonCollectionView.swift index 2999347..aa1c9f5 100644 --- a/SEDaily-IOS/SkeletonCollectionView.swift +++ b/SEDaily-IOS/SkeletonCollectionView.swift @@ -7,53 +7,53 @@ // import UIKit -import KoalaTeaFlowLayout + private let reuseIdentifier = "Cell" class SkeletonCollectionView: UIView, UICollectionViewDataSource { var collectionView: UICollectionView! - + override init(frame: CGRect) { super.init(frame: frame) self.collectionView = UICollectionView(frame: frame, collectionViewLayout: UICollectionViewLayout()) self.addSubview(self.collectionView) self.collectionView.dataSource = self - - self.collectionView!.register(PodcastCell.self, forCellWithReuseIdentifier: reuseIdentifier) - - let layout = KoalaTeaFlowLayout(cellWidth: UIView.getValueScaledByScreenWidthFor(baseValue: 158), - cellHeight: UIView.getValueScaledByScreenHeightFor(baseValue: 250), - topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 12), - leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 20), - cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 8)) - self.collectionView?.collectionViewLayout = layout - - self.collectionView?.backgroundColor = .white + + self.collectionView!.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier) + + let layout = KoalaTeaFlowLayout(cellWidth: Helpers.getScreenWidth(), + cellHeight: UIView.getValueScaledByScreenWidthFor(baseValue: 185.0), + topBottomMargin: UIView.getValueScaledByScreenHeightFor(baseValue: 10), + leftRightMargin: UIView.getValueScaledByScreenWidthFor(baseValue: 0), + cellSpacing: UIView.getValueScaledByScreenWidthFor(baseValue: 10)) + self.collectionView?.collectionViewLayout = layout + self.collectionView?.backgroundColor = Stylesheet.Colors.light } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: UICollectionViewDataSource - + func numberOfSections(in collectionView: UICollectionView) -> Int { // #warning Incomplete implementation, return the number of sections return 1 } - - + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { // #warning Incomplete implementation, return the number of items return 6 } - + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! PodcastCell - + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as? ItemCollectionViewCell else { + return UICollectionViewCell() + } + // Configure the cell cell.setupSkeletonCell() - + return cell } } diff --git a/SEDaily-IOS/StateController.swift b/SEDaily-IOS/StateController.swift new file mode 100644 index 0000000..56ffcb4 --- /dev/null +++ b/SEDaily-IOS/StateController.swift @@ -0,0 +1,26 @@ +// +// StateController.swift +// ExpandableOverlay +// +// Created by Dawid Cedrych on 6/18/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation + +class StateController { + + var isFirstLoad = true + + var isOverlayShowing = false + + private var currentlyPlayingId: String = "" + + func setCurrentlyPlaying(id: String) { + currentlyPlayingId = id + } + + func getCurrentlyPlayingId()-> String { + return currentlyPlayingId + } +} diff --git a/SEDaily-IOS/Stylesheet.swift b/SEDaily-IOS/Stylesheet.swift index 66a179c..df2852f 100644 --- a/SEDaily-IOS/Stylesheet.swift +++ b/SEDaily-IOS/Stylesheet.swift @@ -8,53 +8,56 @@ import UIKit import SwifterSwift -import UIFontComplete + // MARK: - Stylesheet enum Stylesheet { - + enum Colors { static let white = UIColor(hex: 0xFFFFFF)! static let gray = UIColor(hex: 0xD6D4D2)! static let offBlack = UIColor(hex: 0x262626)! static let offWhite = UIColor(hex: 0xF9F9F9)! static let clear = UIColor.clear - + static let blackText = UIColor(hex: 0x36322E)! static let grayText = UIColor(hex: 0x979390)! - - static let base = UIColor(hex: 0x4054b2)! + + static let base = UIColor(hex: 0x714CFE)! + static let dark = UIColor(hex: 0x252633)! + static let grey = UIColor(hex: 0x888891)! + static let light = UIColor(hex: 0xf4f7fa)! + static let lightTransparent = UIColor(hex: 0x8A8C8C, transparency: 0.1) + + static let baseLight = UIColor(hex: 0x5366CD)! static let light1 = UIColor(hex: 0xFFC082)! static let light2 = UIColor(hex: 0xFFAB58)! static let dark1 = UIColor(hex: 0xE77607)! static let dark2 = UIColor(hex: 0xB25800)! - + static let secondaryColor = UIColor(hex: 0xfc4482)! static let bufferColor = UIColor(hex: 0xb6b8b9)! + } - - enum Fonts { - static let Regular = Font.helveticaNeue - static let Bold = Font.helveticaNeueBold - } - + + enum Contexts { enum Global { - static let StatusBarStyle = UIStatusBarStyle.lightContent - static let StatusBarBackgroundColor = Colors.base + static let StatusBarStyle = UIStatusBarStyle.default + static let StatusBarBackgroundColor = Colors.white } - + enum NavigationController { - static let BarTintColor = Colors.white - static let BarColor = Colors.base + static let BarTintColor = Colors.base + static let BarColor = Colors.white } - + enum EventHeader { static let BackgroundColor = Colors.light2 static let TextColor = Colors.white } } - + enum CellContexts { enum EventsCell { static let titleTextColor = Colors.blackText @@ -64,21 +67,23 @@ enum Stylesheet { static let unfollowTintColor = Colors.gray } } - + } + + // MARK: - Apply Stylesheet extension Stylesheet { static func applyOn(_ navVC: UINavigationController) { - typealias context = Contexts.NavigationController - + typealias Context = Contexts.NavigationController + let navBar = navVC.navigationBar - + navBar.isTranslucent = false - navBar.setColors(background: context.BarColor, text: context.BarTintColor) + navBar.setColors(background: Context.BarColor, text: Context.BarTintColor) navBar.setBackgroundImage(UIImage(), for: .default) navBar.shadowImage = UIImage() - + UIApplication.shared.statusBarView?.backgroundColor = Contexts.Global.StatusBarBackgroundColor } } diff --git a/SEDaily-IOS/SubscriptionModel.swift b/SEDaily-IOS/SubscriptionModel.swift new file mode 100644 index 0000000..ed6a651 --- /dev/null +++ b/SEDaily-IOS/SubscriptionModel.swift @@ -0,0 +1,33 @@ +// +// SubscriptionModel.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 1/15/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import Foundation + +public struct StripeModel: Codable { + let _id: String + let planFrequency: String + let dateCreated: String +} + +public struct SubscriptionModel: Codable { + let _id: String + let createdAt: String + let username: String + let subscription: StripeModel +} + +extension SubscriptionModel { + func getCreatedAtDate() -> Date? { + let date = Date(iso8601String: self.subscription.dateCreated) + return date + } + + func getPlanFrequency() -> String { + return self.subscription.planFrequency.capitalized + } +} diff --git a/SEDaily-IOS/SubscriptionStatusViewController.swift b/SEDaily-IOS/SubscriptionStatusViewController.swift new file mode 100644 index 0000000..6e844cd --- /dev/null +++ b/SEDaily-IOS/SubscriptionStatusViewController.swift @@ -0,0 +1,108 @@ +// +// SubscriptionStatusViewController.swift +// SEDaily-IOS +// +// Created by Craig Holliday on 1/15/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit + +class SubscriptionStatusViewController: UIViewController { + + let api = API() + var yearlyContainerView = UIView() + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .white + + let leftBarButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(self.dismissSelf)) + self.navigationItem.leftBarButtonItem = leftBarButton + + self.title = "Your Subscription" + + self.yearlyContainerView = UIView(leftInset: 20, topInset: 0, width: 335, height: 160) + self.yearlyContainerView.y = 20 + + self.yearlyContainerView.backgroundColor = .white + self.yearlyContainerView.layer.cornerRadius = yearlyContainerView.height / 16 + self.view.addSubview(yearlyContainerView) + + let typeLabel = UILabel(leftInset: 0, topInset: 30, width: 335, height: 40) + typeLabel.textAlignment = .center + yearlyContainerView.addSubview(typeLabel) + + let startDateLeftInset = typeLabel.frame.minX + let startDateTopInset = typeLabel.frame.maxY + let startDateLabel = UILabel(leftInset: startDateLeftInset, topInset: startDateTopInset, width: 335, height: 40) + startDateLabel.textAlignment = .center + yearlyContainerView.addSubview(startDateLabel) + + let selectButton = UIButton(leftInset: 0, topInset: 0, width: 220, height: 40) + let leftInset = yearlyContainerView.center.x - 20 - (selectButton.width / 2) + let topInset = startDateLabel.frame.maxY + UIView.getValueScaledByScreenHeightFor(baseValue: 15) + selectButton.x = leftInset + selectButton.y = topInset + selectButton.setTitle("Cancel Subscription", for: .normal) + selectButton.setTitleColor(.white, for: .normal) + selectButton.backgroundColor = Stylesheet.Colors.secondaryColor + selectButton.cornerRadius = selectButton.height / 2 + selectButton.addTarget(self, action: #selector(self.cancelButtonPressed), for: .touchUpInside) + yearlyContainerView.addSubview(selectButton) + + api.loadUserInfo { (subscriptionModel) in + guard let subscriptionModel = subscriptionModel else { + assertionFailure("Subscription model is nil") + return + } + + let fontSize = UIView.getValueScaledByScreenWidthFor(baseValue: 22) + + let attributedText = NSMutableAttributedString(string: "Your plan: ", attributes: [NSAttributedStringKey.font: UIFont.boldSystemFont(ofSize: fontSize)]) + let attributedText2 = NSMutableAttributedString(string: subscriptionModel.getPlanFrequency(), attributes: [NSAttributedStringKey.font: UIFont.systemFont(ofSize: fontSize)]) + attributedText.append(attributedText2) + typeLabel.attributedText = attributedText + + let startDateAttrText1 = NSMutableAttributedString(string: "Start Date: ", attributes: [NSAttributedStringKey.font: UIFont.boldSystemFont(ofSize: fontSize)]) + let startDateAttrText2 = NSMutableAttributedString(string: subscriptionModel.getCreatedAtDate()!.dateString(), attributes: [NSAttributedStringKey.font: UIFont.systemFont(ofSize: fontSize)]) + startDateAttrText1.append(startDateAttrText2) + startDateLabel.attributedText = startDateAttrText1 + } + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + + @objc func dismissSelf() { + self.dismiss(animated: true, completion: nil) + } + + @objc func cancelButtonPressed() { + let alert = UIAlertController(title: "🛑🛑 Are you sure? 🛑🛑", message: "You are about to cancel your subscription", preferredStyle: UIAlertControllerStyle.alert) + alert.addAction(UIAlertAction(title: "Yes I'm sure.", style: .destructive, handler: { (_) in + self.cancelSubscription() + })) + alert.addAction(UIAlertAction(title: "No stop now!", style: .cancel, handler: nil)) + self.present(alert, animated: true, completion: nil) + } + + func cancelSubscription() { + ProgressIndicator.showBlockingProgress() + api.stripeCancelSubscription { (error) in + ProgressIndicator.hideBlockingProgress() + guard let error = error else { + // Show Success + Helpers.alertWithMessageCustomAction(title: L10n.genericSuccess, message: "Your subscription has been canceled.", actionTitle: "Thanks!", completionHandler: { + self.dismiss(animated: true, completion: nil) + }) + + return + } + // Show Error + Helpers.alertWithMessage(title: L10n.genericError, message: error.localizedDescription) + } + } +} diff --git a/SEDaily-IOS/SummaryCell.swift b/SEDaily-IOS/SummaryCell.swift new file mode 100644 index 0000000..0fa1be5 --- /dev/null +++ b/SEDaily-IOS/SummaryCell.swift @@ -0,0 +1,68 @@ +// +// SummaryCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/5/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import Reusable +import UIKit + +class SummaryCell: UITableViewCell, Reusable { + + private var label: UILabel = UILabel() + + var viewModel: ViewModel = ViewModel() { + didSet { + label.text = viewModel.text + setupLayout(style: viewModel.style) + + } + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + contentView.addSubview(label) + self.isUserInteractionEnabled = true + self.selectionStyle = .none + } + + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } +} + + +extension SummaryCell { + private func setupLayout(style: ViewModel.Style) { + + label.snp.updateConstraints { (make) in + make.left.right.equalToSuperview().offset(style.marginX) + make.right.equalToSuperview().inset(style.marginX) + make.top.equalToSuperview().offset(style.marginY) + make.bottom.equalToSuperview().inset(style.marginY) + } + label.textAlignment = style.alignment + label.font = style.font + label.textColor = style.color + label.numberOfLines = 0 + } +} + +// MARK: - ViewModel +extension SummaryCell { + struct ViewModel { + struct Style { + var marginX: CGFloat = 0 + var marginY: CGFloat = 0 + var font: UIFont = UIFont(name: "Roboto", size: 10.0)! //arbitrary + var color = UIColor.clear + var alignment = NSTextAlignment.left + } + var text = "" + var style = Style() + } +} diff --git a/SEDaily-IOS/SwitchCell.swift b/SEDaily-IOS/SwitchCell.swift new file mode 100644 index 0000000..e449622 --- /dev/null +++ b/SEDaily-IOS/SwitchCell.swift @@ -0,0 +1,95 @@ +// +// SwitchCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/6/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit +import Reusable + +protocol SwitchCellDelegate: class { + func switchCell(_ cell: SwitchCell, didToggle value: Bool) +} + +class SwitchCell: UITableViewCell, Reusable { + private var label: UILabel = UILabel() + var toggle: UISwitch = UISwitch() + private var separator: UIView = UIView() + + weak var delegate: SwitchCellDelegate? + + var viewModel: ViewModel = ViewModel() { + didSet { + label.text = viewModel.text + setupLayout() + setupTargets() + } + } + + + + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + contentView.addSubview(label) + contentView.addSubview(toggle) + contentView.addSubview(separator) + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } +} + + +extension SwitchCell { + private func setupTargets() { + toggle.addTarget(self, action: #selector(SwitchCell.switchValueDidChange), for: .touchUpInside) + } + + @objc private func switchValueDidChange(sender: UISwitch) { + delegate?.switchCell(self, didToggle: sender.isOn) + } +} +extension SwitchCell { + private func setupLayout() { + + selectionStyle = .none + + label.textColor = Stylesheet.Colors.dark + label.baselineAdjustment = .alignCenters + label.numberOfLines = 0 + label.font = UIFont(name: "OpenSans", size: UIView.getValueScaledByScreenWidthFor(baseValue: 15)) + + toggle.tintColor = Stylesheet.Colors.light + toggle.onTintColor = Stylesheet.Colors.base + + separator.backgroundColor = Stylesheet.Colors.light + + label.snp.makeConstraints { (make) in + make.left.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerY.equalToSuperview() + make.top.equalToSuperview().offset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.bottom.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + } + + toggle.snp.makeConstraints { (make) in + make.right.equalToSuperview().inset(UIView.getValueScaledByScreenWidthFor(baseValue: 15.0)) + make.centerY.equalToSuperview() + } + separator.snp.makeConstraints { (make) -> Void in + make.left.right.bottom.equalToSuperview() + make.height.equalTo(2.0) + } + } +} + +// MARK: - ViewModel +extension SwitchCell { + struct ViewModel { + var text = "" + } +} diff --git a/SEDaily-IOS/TagsCell.swift b/SEDaily-IOS/TagsCell.swift new file mode 100644 index 0000000..3300118 --- /dev/null +++ b/SEDaily-IOS/TagsCell.swift @@ -0,0 +1,98 @@ +// +// TagsCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/14/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable +import Tags + +class TagsCell: UITableViewCell, Reusable, UIScrollViewDelegate { + + let tagsView = TagsView() + let scrollView = UIScrollView() + + var topics:[String] = [] { willSet { + guard newValue != self.topics else { return } + } + didSet { + tagsView.set(contentsOf: topics) + tagsView.lastTag = "+" + print(tagsView.frame.width) + } + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupLayout() + scrollView.delegate = self + + } + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func layoutSubviews() { + print(self.contentView.height) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if scrollView.contentOffset.y > 0 || scrollView.contentOffset.y < 0 { + scrollView.contentOffset.y = 0 + } + } + +} + +extension TagsCell { + private func setupLayout() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + tagsView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = true + scrollView.backgroundColor = .white + scrollView.contentSize = tagsView.frame.size + let label: UILabel = UILabel() + label.text = "" + contentView.addSubview(scrollView) + scrollView.addSubview(tagsView) + scrollView.addSubview(label) + // layer radius + tagsView.tagLayerRadius = 6 + // layer width + tagsView.tagLayerWidth = 1 + // layer color + tagsView.tagLayerColor = Stylesheet.Colors.base + // text color + tagsView.tagTitleColor = Stylesheet.Colors.base + // background color + tagsView.tagBackgroundColor = .white + // font + tagsView.tagFont = .systemFont(ofSize: 15) + // text longer ... + tagsView.lineBreakMode = .byTruncatingMiddle + + scrollView.snp.makeConstraints { (make) in + make.top.left.right.bottom.equalToSuperview() + } + + tagsView.snp.makeConstraints { (make) in + make.top.left.right.bottom.equalToSuperview() + //make.height.equalTo(30.0) + make.width.equalTo(5000.0) // this is a workaround for scrollView + } + + if #available(iOS 11, *) { + scrollView.contentInsetAdjustmentBehavior = .never + } + scrollView.contentSize.height = 1.0 + //scrollView.contentSize = tagsView.frame.size + + + } +} + + diff --git a/SEDaily-IOS/ThreadHeaderView.swift b/SEDaily-IOS/ThreadHeaderView.swift new file mode 100644 index 0000000..9b9a095 --- /dev/null +++ b/SEDaily-IOS/ThreadHeaderView.swift @@ -0,0 +1,54 @@ +// +// ThreadHeaderView.swift +// SEDaily-IOS +// +// Created by jason on 4/28/18. +// Copyright © 2018 Koala Tea. All rights reserved. +// + +import UIKit +import Down + +class ThreadHeaderView: UIView { + + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var contentLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + @IBOutlet weak var commentCountLabel: UILabel! + + var thread: ForumThread? { + didSet { + titleLabel.text = thread?.title + if let threadContent = thread?.content { + let content = Down(markdownString: threadContent) + contentLabel.attributedText = try? content.toAttributedString() + } + commentCountLabel.text = thread?.getCommentsSummary() + if let author = thread?.author { + authorLabel.text = (author.name != nil) ? author.name : author.username + } + dateLabel.text = thread?.getDatedCreatedPretty() + if let thread = thread { + Analytics2.forumThreadViewed(forumThread: thread) + Tracker.logForumThreadViewed(forumThread: thread) + } + } + } + + /* + // Only override draw() if you perform custom drawing. + // An empty implementation adversely affects performance during animation. + override func draw(_ rect: CGRect) { + // Drawing code + } + */ + + override func layoutSubviews() { + super.layoutSubviews() + titleLabel.preferredMaxLayoutWidth = titleLabel.bounds.width + + contentLabel.preferredMaxLayoutWidth = titleLabel.bounds.width + } + +} diff --git a/SEDaily-IOS/Topic.swift b/SEDaily-IOS/Topic.swift new file mode 100644 index 0000000..e4c95a1 --- /dev/null +++ b/SEDaily-IOS/Topic.swift @@ -0,0 +1,27 @@ +// +// Topic.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/14/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation + +public struct Topic: Codable { + let _id: String + let name: String + let slug: String + let status: String + let postCount: Int +} + +extension Topic: Equatable { + public static func == (lhs: Topic, rhs: Topic) -> Bool { + return lhs._id == rhs._id && + lhs.name == rhs.name && + lhs.slug == rhs.slug && + lhs.status == rhs.status && + lhs.postCount == rhs.postCount + } +} diff --git a/SEDaily-IOS/TopicTableViewController.swift b/SEDaily-IOS/TopicTableViewController.swift new file mode 100644 index 0000000..75866ee --- /dev/null +++ b/SEDaily-IOS/TopicTableViewController.swift @@ -0,0 +1,220 @@ +//// +//// TopicTableViewController.swift +//// SEDaily-IOS +//// +//// Created by Dawid Cedrych on 5/15/19. +//// Copyright © 2019 Altalogy. All rights reserved. +//// +// +//// +//// TopicTableViewController.swift +//// SEDaily-IOS +//// +//// Created by Dawid Cedrych on 5/15/19. +//// Copyright © 2019 Altalogy. All rights reserved. +//// +// +//import UIKit +//import KTResponsiveUI +//import StatefulViewController +// +//class TopicTableViewController: UIViewController, StatefulViewController { +// +// private let podcastViewModelController = PodcastViewModelController() +// weak var audioOverlayDelegate: AudioOverlayDelegate? +// +// var topic: Topic = Topic(_id: "", name: "", slug: "", status: "", postCount: 0) +// +// private var progressController = PlayProgressModelController() +// +// private let pageSize = 10 +// private let preloadMargin = 5 +// private var lastLoadedPage = 0 +// +// private var tableView: UITableView? +// +// override func viewDidLoad() { +// super.viewDidLoad() +// +// NotificationCenter.default.addObserver( +// self, +// selector: #selector(self.onDidReceiveData(_:)), +// name: .viewModelUpdated, +// object: nil) +// +// +// self.tableView = UITableView() +// if let tableView = self.tableView { +// tableView.dataSource = self +// tableView.delegate = self +// self.view.addSubview(tableView) +// tableView.tableFooterView = UIView() +// tableView.snp.makeConstraints { (make) in +// make.edges.equalToSuperview() +// } +// tableView.register(cellType: PodcastTableViewCell.self) +// tableView.separatorStyle = .singleLine +// tableView.rowHeight = UITableViewAutomaticDimension +// tableView.estimatedRowHeight = 50.0 +// tableView.backgroundColor = Stylesheet.Colors.light +// +// } +// self.title = topic.name +// +// self.loadingView = StateView( +// frame: CGRect.zero, +// text: L10n.fetchingSearch, +// showLoadingIndicator: true, +// showRefreshButton: false, +// delegate: nil) +// self.loadingView?.isUserInteractionEnabled = false +// +// self.emptyView = StateView( +// frame: CGRect.zero, +// text: L10n.emptySearch, +// showLoadingIndicator: false, +// showRefreshButton: false, +// delegate: nil) +// self.emptyView?.isUserInteractionEnabled = false +// +// } +// +// override func viewWillAppear(_ animated: Bool) { +// super.viewWillAppear(animated) +// +// self.setupInitialViewState() +// progressController.retrieve() +// self.tableView?.reloadData() +// self.getData(lastIdentifier: "", nextPage: 0, firstSearch: true) +// } +// +// override func viewWillDisappear(_ animated: Bool) { +// super.viewWillDisappear(animated) +// +// } +// +// deinit { +// // perform the deinitialization +// NotificationCenter.default.removeObserver(self) +// } +// +// func hasContent() -> Bool { +// return podcastViewModelController.viewModelsCount > 0 +// } +//} +// +//extension TopicTableViewController: UITableViewDelegate { +// +// func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { +// return UITableViewAutomaticDimension +// } +// +// func numberOfSections(in tableView: UITableView) -> Int { +// return 1 +// } +// +// func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { +// return self.podcastViewModelController.viewModelsCount +// } +// +// func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { +// if let viewModel = self.podcastViewModelController.viewModel(at: indexPath.row) { +// if let audioOverlayDelegate = self.audioOverlayDelegate { +// let vc = EpisodeViewController(nibName: nil, bundle: nil, audioOverlayDelegate: audioOverlayDelegate) +// vc.viewModel = viewModel +// self.navigationController?.pushViewController(vc, animated: true) +// } +// } +// } +//} +// +//extension TopicTableViewController: UITableViewDataSource { +// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// +// guard let viewModel = podcastViewModelController.viewModel(at: indexPath.row) else { return UITableViewCell() } +// +// let cell: PodcastTableViewCell = tableView.dequeueReusableCell(for: indexPath) +// cell.selectionStyle = .none +// let upvoteService = UpvoteService(podcastViewModel: viewModel) +// let bookmarkService = BookmarkService(podcastViewModel: viewModel) +// let downloadService = DownloadService(podcastViewModel: viewModel) +// +// cell.playProgress = progressController.episodesPlayProgress[viewModel._id] ?? PlayProgress(id: "", currentTime: 0.0, totalLength: 0.0) +// +// +// cell.viewModel = viewModel +// cell.upvoteService = upvoteService +// cell.bookmarkService = bookmarkService +// +// cell.commentShowCallback = { [weak self] in +// self?.commentsButtonPressed(viewModel) +// } +// +// return cell +// } +//} +// +// +// +// +//extension TopicTableViewController { +// func checkPage(currentIndexPath: IndexPath, lastIndexPath: IndexPath, lastIdentifier: String) { +// let nextPage: Int = Int(currentIndexPath.item / self.pageSize) + 1 +// let preloadIndex = nextPage * self.pageSize - self.preloadMargin +// +// if (currentIndexPath.item >= preloadIndex && self.lastLoadedPage < nextPage) || currentIndexPath == lastIndexPath { +// self.getData(lastIdentifier: lastIdentifier, nextPage: nextPage, firstSearch: false) +// } +// } +// +// func getData(lastIdentifier: String, nextPage: Int, firstSearch: Bool) { +// self.startLoading() +// podcastViewModelController.fetchTopicData( +// slug: topic._id, +// createdAtBefore: lastIdentifier, +// firstSearch: firstSearch, +// onSuccess: { [weak self] in +// self?.endLoading() +// self?.lastLoadedPage = nextPage +// DispatchQueue.main.async { +// self?.tableView?.reloadData() +// } }, +// onFailure: { [weak self] (apiError) in +// self?.endLoading() +// log.error(apiError ?? "") }) +// } +//} +// +// +// +// +// +//extension TopicTableViewController { +// +// func commentsButtonPressed(_ viewModel: PodcastViewModel) { +// Analytics2.podcastCommentsViewed(podcastId: viewModel._id) +// let commentsViewController: CommentsViewController = CommentsViewController() +// if let thread = viewModel.thread { +// commentsViewController.rootEntityId = thread._id +// self.navigationController?.pushViewController(commentsViewController, animated: true) +// } +// } +//} +// +//extension TopicTableViewController { +// @objc func onDidReceiveData(_ notification: Notification) { +// if let data = notification.userInfo as? [String: PodcastViewModel] { +// for (_, viewModel) in data { +// viewModelDidChange(viewModel: viewModel) +// } +// } +// } +//} +// +// +//extension TopicTableViewController { +// private func viewModelDidChange(viewModel: PodcastViewModel) { +// self.podcastViewModelController.update(with: viewModel) +// } +//} +// diff --git a/SEDaily-IOS/UIColor+Extensions.swift b/SEDaily-IOS/UIColor+Extensions.swift new file mode 100644 index 0000000..902407e --- /dev/null +++ b/SEDaily-IOS/UIColor+Extensions.swift @@ -0,0 +1,24 @@ +// +// UIColor+Extensions.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/30/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation +import UIKit + +extension UIColor { + func brightened(by factor: CGFloat) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s, brightness: b * factor, alpha: a) + } + + func getGradientColors(brightenedBy: CGFloat) -> [Any] { + return [self.cgColor, + self.brightened(by: brightenedBy).cgColor, + self.cgColor] + } +} diff --git a/SEDaily-IOS/URLSchemaHelper.swift b/SEDaily-IOS/URLSchemaHelper.swift new file mode 100644 index 0000000..38a6fd6 --- /dev/null +++ b/SEDaily-IOS/URLSchemaHelper.swift @@ -0,0 +1,25 @@ +// +// URLSchemaHelper.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/14/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import Foundation + +class URLSchemaHelper { + class func addSchema(url: String)->String { + var urlString: String = url + let urlPrefix = urlString.prefix(4) + if urlPrefix != "http" { + // Defaulting to http: + if urlPrefix.prefix(3) == "://" { + urlString = "http\(url)" + } else { + urlString = "http://\(url)" + } + } + return urlString + } +} diff --git a/SEDaily-IOS/UpvoteService.swift b/SEDaily-IOS/UpvoteService.swift new file mode 100644 index 0000000..6befcad --- /dev/null +++ b/SEDaily-IOS/UpvoteService.swift @@ -0,0 +1,78 @@ +// +// UpvoteService.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 4/23/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +protocol UpvoteServiceUIDelegate: class { + func upvoteUIDidChange(isUpvoted: Bool, score: Int) + func upvoteUIImmediateUpdate() +} + + +import Foundation + +class UpvoteService { + + private let networkService = API() + + var podcastViewModel: PodcastViewModel { + didSet { + updateViewModel() + updateUI(isUpvoted: self.podcastViewModel.isUpvoted, score: self.podcastViewModel.score) + } + } + + weak var UIDelegate: UpvoteServiceUIDelegate? + + init(podcastViewModel: PodcastViewModel) { + self.podcastViewModel = podcastViewModel + } + + func upvote() { + guard UserManager.sharedInstance.isCurrentUserLoggedIn() == true else { + Helpers.alertWithMessage(title: Helpers.Alerts.error, message: Helpers.Messages.youMustLogin, completionHandler: nil) + return + } + self.UIDelegate?.upvoteUIImmediateUpdate() + + let podcastId = self.podcastViewModel._id + networkService.upvotePodcast(podcastId: podcastId, completion: { [weak self] (success, active) in + guard success != nil else { + self?.UIDelegate?.upvoteUIImmediateUpdate() + return + } + if success == true { + guard let active = active else { return } + self?.addScore(active: active) + self?.setStatus(active: active) + } else { self?.UIDelegate?.upvoteUIImmediateUpdate() } + }) + } + + func addScore(active: Bool) { + guard active != false else { + self.setScoreTo(self.podcastViewModel.score - 1) + return + } + self.setScoreTo(self.podcastViewModel.score + 1) + } + + private func updateViewModel() { + let userInfo = ["viewModel": podcastViewModel] + NotificationCenter.default.post(name: .viewModelUpdated, object: nil, userInfo: userInfo) + } + private func updateUI(isUpvoted: Bool, score: Int) { + self.UIDelegate?.upvoteUIDidChange(isUpvoted: isUpvoted, score: self.podcastViewModel.score) + } + + func setScoreTo(_ score: Int) { + guard self.podcastViewModel.score != score else { return } + self.podcastViewModel.score = score + } + private func setStatus(active: Bool) { + self.podcastViewModel.isUpvoted = active + } +} diff --git a/SEDaily-IOS/User+ProfileViewController.Section.swift b/SEDaily-IOS/User+ProfileViewController.Section.swift new file mode 100644 index 0000000..d8e42d4 --- /dev/null +++ b/SEDaily-IOS/User+ProfileViewController.Section.swift @@ -0,0 +1,35 @@ +// +// User+ProfileViewController.Section.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 6/5/19. +// Copyright © 2019 Altalogy. All rights reserved. +// + +import Foundation +import UIKit + +extension User { + subscript(row: ProfileViewController.Section.SummaryRow) -> Any? { + switch row { + case .bio: return bio + case .name: return name ?? username + case .username: return username + case .avatar: return URL(string: avatarUrl ?? "https://sd-profile-pictures.s3.amazonaws.com/5d03934823a39c002ae7dd70") + case .link: return website + case .signInPlaceholder: return nil + } + } +} + +extension UITableView { + func dequeueReusableCell(for indexPath: IndexPath) -> Cell? { + return dequeueReusableCell(withIdentifier: String(describing: Cell.self), for: indexPath) as? Cell + } + + func dequeueReusableCell(with type: Cell.Type, for indexPath: IndexPath) -> Cell? { + return dequeueReusableCell(withIdentifier: String(describing: type), for: indexPath) as? Cell + } +} + + diff --git a/SEDaily-IOS/UserModel.swift b/SEDaily-IOS/UserModel.swift index 89ed805..b05f9db 100644 --- a/SEDaily-IOS/UserModel.swift +++ b/SEDaily-IOS/UserModel.swift @@ -10,122 +10,159 @@ import UIKit import SwifterSwift public struct User: Codable { - let key: Int = 1 - let firstName: String - let lastName: String - let email: String - let token: String - let pushNotificationsSetting: Bool = false - let deviceToken: String? = nil - - init(firstName: String, - lastName: String, - email: String, - token: String) { - self.firstName = firstName - self.lastName = lastName - self.email = email - self.token = token - } - - init() { - self.firstName = "" - self.lastName = "" - self.email = "" - self.token = "" - } - - // Mark: Getters - - func getFullName() -> String { - return self.firstName + self.lastName - } - - func isLoggedIn() -> Bool { - if token != "" { - return true - } - return false - } + let _id: String + let username: String + let email: String? + var token: String? + let pushNotificationsSetting: Bool? = false + let deviceToken: String? = nil + var hasPremium: Bool? + + var isMainUser: Bool { + get { + guard let token = token else { return false } + return !token.isEmpty + } + } + + let avatarUrl: String? + let bio: String? + let website: String? + let name: String? + + + + init( + _id: String = "", + username: String = "", + email: String = "", + token: String = "", + hasPremium: Bool = false, + avatarUrl: String? = nil, + bio: String? = nil, + website: String? = nil, + name: String? = nil + ) { + + self._id = _id + self.username = username + self.email = email + self.token = token + self.hasPremium = hasPremium + self.avatarUrl = avatarUrl + self.bio = bio + self.website = website + self.name = name + } + + // MARK: Getters + + + + func isLoggedIn() -> Bool { + if token != "" { + return true + } + return false + } } extension User: Equatable { - public static func ==(lhs: User, rhs: User) -> Bool { - return lhs.key == rhs.key && - lhs.firstName == rhs.firstName && - lhs.lastName == rhs.lastName && - lhs.email == rhs.email && - lhs.token == rhs.token && - lhs.pushNotificationsSetting == rhs.pushNotificationsSetting && - lhs.deviceToken == rhs.deviceToken - } + public static func == (lhs: User, rhs: User) -> Bool { + return + lhs.username == rhs.username && + lhs.email == rhs.email && + lhs.token == rhs.token && + lhs.pushNotificationsSetting == rhs.pushNotificationsSetting && + lhs.deviceToken == rhs.deviceToken && + lhs.hasPremium == rhs.hasPremium && + lhs.avatarUrl == rhs.avatarUrl && + lhs.bio == rhs.bio && + lhs.website == rhs.website && + lhs.name == rhs.name + + } } public class UserManager { - static let sharedInstance: UserManager = UserManager() - private init() {} - - let defaults = UserDefaults.standard - - let staticUserKey = "user" - - var currentUser: User = User() { - didSet { - self.saveUserToDefaults(user: self.currentUser) - } - } - - public func getActiveUser() -> User { - switch checkIfSavedUserEqualsCurrentUser() { - case true: - return self.currentUser - case false: - if let retrievedUser = self.retriveCurrentUserFromDefaults() { - if self.currentUser == User() && retrievedUser != User() { - self.setCurrentUser(to: retrievedUser) - return retrievedUser - } - } - self.saveUserToDefaults(user: self.currentUser) - return self.currentUser - } - } - - public func setCurrentUser(to newUser: User) { - guard currentUser != newUser else { return } - self.currentUser = newUser - } - - public func isCurrentUserLoggedIn() -> Bool { - let token = self.currentUser.token - guard token != "" else { return false } - return true - } - - public func logoutUser() { - self.setCurrentUser(to: User()) - NotificationCenter.default.post(name: .loginChanged, object: nil) - } - - private func checkIfSavedUserEqualsCurrentUser() -> Bool { - guard let retrievedUser = self.retriveCurrentUserFromDefaults() else { return false } - guard retrievedUser == self.currentUser else { return false } - return true - } - - private func saveUserToDefaults(user: User) { - let encoder = JSONEncoder() - if let encoded = try? encoder.encode(user) { - defaults.set(encoded, forKey: staticUserKey) - } - } - - private func retriveCurrentUserFromDefaults() -> User? { - let decoder = JSONDecoder() - if let returnedEncodedUser = defaults.data(forKey: staticUserKey), - let user = try? decoder.decode(User.self, from: returnedEncodedUser) { - return user - } - return nil - } + static let sharedInstance: UserManager = UserManager() + + // Put in this init for tests, but it'd be great to turn this into a non-singleton in general + init(userDefaults: UserDefaultsProtocol = UserDefaults.standard) { + defaults = userDefaults + } + + let defaults: UserDefaultsProtocol + + let staticUserKey = "user" + + var currentUser: User = User() { + didSet { + self.saveUserToDefaults(user: self.currentUser) + } + } + + public func getActiveUser() -> User { + switch checkIfSavedUserEqualsCurrentUser() { + case true: + return self.currentUser + case false: + if let retrievedUser = self.retriveCurrentUserFromDefaults() { + if self.currentUser == User() && retrievedUser != User() { + self.setCurrentUser(to: retrievedUser) + return retrievedUser + } + } + self.saveUserToDefaults(user: self.currentUser) + return self.currentUser + } + } + + public func setCurrentUser(to newUser: User) { + guard currentUser != newUser else { return } + self.currentUser = newUser + } + + public func isCurrentUserLoggedIn() -> Bool { + let token = self.currentUser.token + guard token != "" else { return false } + return true + } + + public func logoutUser() { + self.setCurrentUser(to: User()) + + // Clear disk cache + PodcastDataSource.clean(diskKey: .PodcastFolder) + NotificationCenter.default.post(name: .loginChanged, object: nil) + } + + private func checkIfSavedUserEqualsCurrentUser() -> Bool { + guard let retrievedUser = self.retriveCurrentUserFromDefaults() else { return false } + guard retrievedUser == self.currentUser else { return false } + return true + } + + private func saveUserToDefaults(user: User) { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(user) { + defaults.set(encoded, forKey: staticUserKey) + } + } + + private func retriveCurrentUserFromDefaults() -> User? { + let decoder = JSONDecoder() + if let returnedEncodedUser = defaults.data(forKey: staticUserKey), + let user = try? decoder.decode(User.self, from: returnedEncodedUser) { + return user + } + return nil + } } + +public protocol UserDefaultsProtocol { + func set(_ value: Any?, forKey defaultName: String) + func data(forKey defaultName: String) -> Data? +} + +extension UserDefaults: UserDefaultsProtocol {} diff --git a/SEDaily-IOS/ViewPlayground.playground/Contents.swift b/SEDaily-IOS/ViewPlayground.playground/Contents.swift index 068516e..991f5d7 100644 --- a/SEDaily-IOS/ViewPlayground.playground/Contents.swift +++ b/SEDaily-IOS/ViewPlayground.playground/Contents.swift @@ -1,5 +1,5 @@ //: A UIKit based Playground for presenting user interface - + import UIKit import PlaygroundSupport import ViewPlayground_Sources @@ -8,24 +8,24 @@ import ViewPlayground_Sources var imageView: UIImageView! var titleLabel: UILabel! var timeDayLabel: UILabel! - + override init(frame: CGRect) { super.init(frame: frame) self.backgroundColor = .white let newContentView = UIView(width: 158, height: 250) self.contentView.frame = newContentView.frame - + imageView = UIImageView(leftInset: 0, topInset: 4, width: 158) self.contentView.addSubview(imageView) self.addSubview(imageView) - + titleLabel = UILabel(origin: imageView.bottomLeftPoint(), topInset: 15, width: 158, height: 50) self.addSubview(titleLabel) - + timeDayLabel = UILabel(origin: titleLabel.bottomLeftPoint(), topInset: 8, width: 158, height: 10) self.addSubview(timeDayLabel) } - + required init(coder aDecoder: NSCoder) { fatalError("init(coder:)") } @@ -39,7 +39,7 @@ import ViewPlayground_Sources extension PodcastCell { func setupSkeletonCell() { - + } } diff --git a/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/Extensions.swift b/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/Extensions.swift index f396ec2..57bcd09 100644 --- a/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/Extensions.swift +++ b/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/Extensions.swift @@ -15,42 +15,42 @@ public extension UIView { public func topRightPoint() -> CGPoint { return CGPoint(x: self.frame.maxX, y: self.frame.minY) } - + public func topMidPoint() -> CGPoint { return CGPoint(x: self.frame.midX, y: self.frame.minY) } - + public func topLeftPoint() -> CGPoint { return CGPoint(x: self.frame.minX, y: self.frame.minY) } - + public func bottomRightPoint() -> CGPoint { return CGPoint(x: self.frame.maxX, y: self.frame.maxY) } - + public func bottomMidPoint() -> CGPoint { return CGPoint(x: self.frame.midX, y: self.frame.maxY) } - + public func bottomLeftPoint() -> CGPoint { return CGPoint(x: self.frame.minX, y: self.frame.maxY) } - + public func leftMidPoint() -> CGPoint { return CGPoint(x: self.frame.minX, y: self.frame.midY) } - + public func rightMidPoint() -> CGPoint { return CGPoint(x: self.frame.maxX, y: self.frame.midY) } - + public class func getValueScaledByScreenHeightFor(baseValue: CGFloat) -> CGFloat { let screenHeight = UIScreen.main.bounds.height let divisor: CGFloat = iphone7Height / baseValue let calculatedHeight = screenHeight / divisor return calculatedHeight } - + public class func getValueScaledByScreenWidthFor(baseValue: CGFloat) -> CGFloat { let screenWidth = UIScreen.main.bounds.width let divisor: CGFloat = iphone7Width / baseValue @@ -58,5 +58,3 @@ public extension UIView { return calculatedWidth } } - - diff --git a/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/UIViewExtensions.swift b/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/UIViewExtensions.swift index 643c6fb..e257f34 100644 --- a/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/UIViewExtensions.swift +++ b/SEDaily-IOS/ViewPlayground.playground/Sources/KTResponsiveUI/KTResponsiveUI/Classes/UIViewExtensions.swift @@ -29,7 +29,7 @@ public extension KTLayoutProtocol where Self: UIView { width: CGFloat, height: CGFloat, keepEqual: Bool) { - + // Calculate position of new frame let cx = origin.x + UIView.getValueScaledByScreenWidthFor(baseValue: leftInset) let cy = origin.y + UIView.getValueScaledByScreenHeightFor(baseValue: topInset) @@ -37,7 +37,7 @@ public extension KTLayoutProtocol where Self: UIView { // Calculate width and height var cWidth = UIView.getValueScaledByScreenWidthFor(baseValue: width) var cHeight = UIView.getValueScaledByScreenHeightFor(baseValue: height) - + // Here we check if either width or height is 0 which we are assuming means that the variable that isn't 0 should be equal to the variable that has been set if keepEqual { if width == 0 { @@ -47,18 +47,18 @@ public extension KTLayoutProtocol where Self: UIView { cHeight = cWidth } } - + // Create new frame let newFrame = CGRect(x: cx, y: cy, width: cWidth, height: cHeight) - + self.init() self.frame = newFrame self.performLayout() } - + // --- init(leftInset: CGFloat, topInset: CGFloat, width: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: topInset, width: width, @@ -66,7 +66,7 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: false) } init(leftInset: CGFloat, width: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: 0, width: width, @@ -74,14 +74,14 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: false) } init(topInset: CGFloat, width: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: topInset, width: width, height: height, keepEqual: false) } - + // --- init(origin: CGPoint, leftInset: CGFloat, topInset: CGFloat, width: CGFloat, height: CGFloat) { self.init(origin: origin, @@ -107,10 +107,10 @@ public extension KTLayoutProtocol where Self: UIView { height: height, keepEqual: false) } - + // --- init(leftInset: CGFloat, topInset: CGFloat, width: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: topInset, width: width, @@ -118,14 +118,14 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: true) } init(leftInset: CGFloat, topInset: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: topInset, width: 0, height: height, keepEqual: true) } - + // --- init(origin: CGPoint, leftInset: CGFloat, topInset: CGFloat, width: CGFloat) { self.init(origin: origin, @@ -143,10 +143,10 @@ public extension KTLayoutProtocol where Self: UIView { height: height, keepEqual: true) } - + // --- init(leftInset: CGFloat, width: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: 0, width: width, @@ -154,7 +154,7 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: true) } init(leftInset: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: leftInset, topInset: 0, width: 0, @@ -162,7 +162,7 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: true) } init(topInset: CGFloat, width: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: topInset, width: width, @@ -170,14 +170,14 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: true) } init(topInset: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: topInset, width: 0, height: height, keepEqual: true) } - + // --- init(origin: CGPoint, leftInset: CGFloat, width: CGFloat) { self.init(origin: origin, @@ -211,10 +211,10 @@ public extension KTLayoutProtocol where Self: UIView { height: height, keepEqual: true) } - + // --- init(width: CGFloat, height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: 0, width: width, @@ -229,10 +229,10 @@ public extension KTLayoutProtocol where Self: UIView { height: height, keepEqual: false) } - + // --- init(width: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: 0, width: width, @@ -240,14 +240,14 @@ public extension KTLayoutProtocol where Self: UIView { keepEqual: true) } init(height: CGFloat) { - self.init(origin: CGPoint(x: 0,y: 0), + self.init(origin: CGPoint(x: 0, y: 0), leftInset: 0, topInset: 0, width: 0, height: height, keepEqual: true) } - + // --- init(origin: CGPoint, width: CGFloat) { self.init(origin: origin, @@ -265,7 +265,7 @@ public extension KTLayoutProtocol where Self: UIView { height: height, keepEqual: true) } - + } // Everything boiled down to a single extension diff --git a/SEDaily-IOS/WebViewCell.swift b/SEDaily-IOS/WebViewCell.swift new file mode 100644 index 0000000..9d5e802 --- /dev/null +++ b/SEDaily-IOS/WebViewCell.swift @@ -0,0 +1,84 @@ +// +// WebViewCell.swift +// SEDaily-IOS +// +// Created by Dawid Cedrych on 5/2/19. +// Copyright © 2019 Koala Tea. All rights reserved. +// + +import UIKit +import Reusable +import WebKit +import SnapKit + + +class WebViewCell: UITableViewCell, Reusable { + + var webView: WKWebView! + + var webViewHeight: CGFloat = 0.0 { + didSet { + snp.removeConstraints() + webView.snp.remakeConstraints { (make) in + make.left.top.right.bottom.equalToSuperview() + make.height.equalTo(webViewHeight).priority(999) + make.width.equalToSuperview() + } + } + } + + var delegate: WebViewCellDelegate? + + + override func awakeFromNib() { + super.awakeFromNib() + // Initialization code + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + webView = WKWebView() + self.contentView.addSubview(webView) + webView.scrollView.snp.makeConstraints { make in + make.left.equalToSuperview().offset(15.0) + make.right.equalToSuperview().offset(-15.0) + } + } + + required init + (coder aDecoder: NSCoder) { + fatalError("init(coder:)") + } + + override func setSelected(_ selected: Bool, animated: Bool) { + super.setSelected(selected, animated: animated) + } +} + + + +extension WebViewCell: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in + if complete != nil { + webView.evaluateJavaScript("document.body.scrollHeight", completionHandler: { (height, error) in + guard let h:CGFloat = height as? CGFloat else { return } + self.delegate?.updateWebViewHeight(didCalculateHeight: h) + }) + } + }) + } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url, + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } else { + decisionHandler(.allow) + } + } +} diff --git a/SEDaily-IOS/fr.lproj/Localizable.strings b/SEDaily-IOS/fr.lproj/Localizable.strings index c93a34e..989496d 100644 --- a/SEDaily-IOS/fr.lproj/Localizable.strings +++ b/SEDaily-IOS/fr.lproj/Localizable.strings @@ -49,3 +49,6 @@ "GenericOkay" = "Okay*"; "GenericOK" = "OK*"; "Play" = "Play*"; +"NoBookmarks" = "No Bookmarks*"; +"LoginSeeBookmarks" = "Login to see your bookmarks*"; +"TapToRefresh" = "Tap to refresh*"; diff --git a/SEDaily-IOSTests/APITests/LoginTests.swift b/SEDaily-IOSTests/APITests/LoginTests.swift new file mode 100644 index 0000000..76f46f5 --- /dev/null +++ b/SEDaily-IOSTests/APITests/LoginTests.swift @@ -0,0 +1,93 @@ +// +// LoginTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 20/01/2018. +// + +import Foundation +import Quick +import Nimble +import Alamofire +import Mockingjay +@testable import SEDaily_IOS + +class LoginTests: QuickSpec { + + override func spec() { + describe("login tests", { + it("performs successful login") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "login_success", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + + var responseSuccess: Bool? + api.login(usernameOrEmail: "", password: "", completion: { success in + responseSuccess = success + }) + + expect(responseSuccess).toEventually(beTrue()) + } + + it("returns false when a wrong password is supplied") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "login_wrongpass", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + + api.login(usernameOrEmail: "", password: "", completion: { success in + responseSuccess = success + }) + + expect(responseSuccess).toEventually(beFalse()) + + } + + it("returns false when a non-existing user tries to log in") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "login_nonexistinguser", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + let api = API() + var responseSuccess: Bool? + + api.login(usernameOrEmail: "", password: "", completion: { success in + responseSuccess = success + }) + expect(responseSuccess).toEventually(beFalse()) + } + + it("returns false when API returns an error") { + // Setup + let error = NSError(domain: "MockDomain", code: 401, userInfo: nil) + self.stub(everything, failure(error)) + + let api = API() + var responseSuccess: Bool? + + api.login(usernameOrEmail: "", password: "", completion: { success in + responseSuccess = success + }) + expect(responseSuccess).toEventually(beFalse()) + } + + it("returns false when the response object is not a dictionary") { + // Setup + self.stub(everything, json(["skl"])) + let api = API() + var responseSuccess: Bool? + + api.login(usernameOrEmail: "", password: "", completion: { success in + responseSuccess = success + }) + expect(responseSuccess).toEventually(beFalse()) + } + }) + } +} diff --git a/SEDaily-IOSTests/APITests/PostsTests.swift b/SEDaily-IOSTests/APITests/PostsTests.swift new file mode 100644 index 0000000..6ec0b03 --- /dev/null +++ b/SEDaily-IOSTests/APITests/PostsTests.swift @@ -0,0 +1,97 @@ +// +// PostsTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 20/01/2018. +// + +import Foundation +import Quick +import Nimble +import Alamofire +import Mockingjay +@testable import SEDaily_IOS + +class PostsTests: QuickSpec { + + override func spec() { + describe("posts calls") { + it("getPostsWith returns a list of posts with a successful call") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "getPostsWith_success", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var successCalled: Bool? = nil, failureCalled: Bool? = nil + + api.getPostsWith(searchTerm: "blockchain", createdAtBefore: "", onSuccess: { podcasts in + expect(podcasts).toNot(beNil()) + expect(podcasts).toNot(beEmpty()) + successCalled = true + }, onFailure: { _ in + failureCalled = true + }) + expect(failureCalled).toEventually(beNil()) + expect(successCalled).toEventually(beTrue()) + } + + it("getPostsWith failure callback called on API call failure") { + // Setup + let error = NSError(domain: "", code: 401, userInfo: nil) + self.stub(everything, failure(error)) + + let api = API() + var successCalled: Bool? = nil, failureCalled: Bool? = nil + + api.getPostsWith(searchTerm: "", createdAtBefore: "", onSuccess: { podcasts in + successCalled = true + }, onFailure: { _ in + failureCalled = true + }) + expect(failureCalled).toEventually(beTrue()) + expect(successCalled).toEventually(beNil()) + + } + + it("getPosts returns the top podcasts successfully") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "getPosts_topPosts", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var successCalled: Bool? = nil, failureCalled: Bool? = nil + + api.getPosts(type: "top", createdAtBefore: "", tags: "", categories: "", onSuccess: { (podcasts) in + expect(podcasts).toNot(beNil()) + expect(podcasts).toNot(beEmpty()) + successCalled = true + }, onFailure: { _ in + failureCalled = true + }) + expect(failureCalled).toEventually(beNil()) + expect(successCalled).toEventually(beTrue()) + } + + it("getPosts failure callback called on API call failure") { + // Setup + let error = NSError(domain: "", code: 401, userInfo: nil) + self.stub(everything, failure(error)) + + let api = API() + var successCalled: Bool? = nil, failureCalled: Bool? = nil + + api.getPosts(type: "top", createdAtBefore: "", tags: "", categories: "", onSuccess: { (podcasts) in + successCalled = true + }, onFailure: { _ in + failureCalled = true + }) + expect(failureCalled).toEventually(beTrue()) + expect(successCalled).toEventually(beNil()) + + } + } + } +} + diff --git a/SEDaily-IOSTests/APITests/RegisterTests.swift b/SEDaily-IOSTests/APITests/RegisterTests.swift new file mode 100644 index 0000000..d0b4c0a --- /dev/null +++ b/SEDaily-IOSTests/APITests/RegisterTests.swift @@ -0,0 +1,85 @@ +// +// RegisterTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 20/01/2018. +// + +import Foundation +import Quick +import Nimble +import Alamofire +import Mockingjay +@testable import SEDaily_IOS + +class RegisterTests: QuickSpec { + + override func spec() { + describe("register tests") { + it("successfully registers a new user") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "register_success", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + + api.register(firstName: "", lastName: "", email: "", username: "", password: "") { success in + responseSuccess = success + } + + expect(responseSuccess).toEventually(beTrue()) + + } + + it("returns false when user already exists") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "register_userexists", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + + api.register(firstName: "", lastName: "", email: "", username: "", password: "") { success in + responseSuccess = success + } + + expect(responseSuccess).toEventually(beFalse()) + + } + + it("returns false when user already exists") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "register_emptyusernamepass", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + + api.register(firstName: "", lastName: "", email: "", username: "", password: "") { success in + responseSuccess = success + } + + expect(responseSuccess).toEventually(beFalse()) + + } + + it("returns false when the response object is not a dictionary") { + // Setup + self.stub(everything, json(["skl"])) + let api = API() + var responseSuccess: Bool? + + api.register(firstName: "", lastName: "", email: "", username: "", password: "") { success in + responseSuccess = success + } + expect(responseSuccess).toEventually(beFalse()) + } + } + + } +} + diff --git a/SEDaily-IOSTests/APITests/VotingTests.swift b/SEDaily-IOSTests/APITests/VotingTests.swift new file mode 100644 index 0000000..2f367b0 --- /dev/null +++ b/SEDaily-IOSTests/APITests/VotingTests.swift @@ -0,0 +1,166 @@ +// +// VotingTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 20/01/2018. +// + +import Quick +import Nimble +import Alamofire +import Mockingjay +@testable import SEDaily_IOS + +class VotingTests: QuickSpec { + + override func spec() { + describe("upvote tests") { + it("returns success for a successful upvote") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "upvote_success", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.upvotePodcast(podcastId: "5a63b51866503d002a70f809") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beTrue()) + expect(responseActive).toEventually(beTrue()) + } + + it("returns false when the podcast doesn't exist") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "upvote_failure", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.upvotePodcast(podcastId: "") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + + it("returns false when the call fails") { + // Setup + let error = NSError(domain: "", code: 401, userInfo: nil) + self.stub(everything, failure(error)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.upvotePodcast(podcastId: "") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + + it("returns false when the upvote response object is not a dictionary") { + // Setup + self.stub(everything, json(["skl"])) + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.upvotePodcast(podcastId: "5a63b51866503d002a70f809") { (success, active) in + responseSuccess = success + responseActive = active + + } + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + } + + describe("downvote tests") { + it("returns success for a successful downvote") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "upvote_success", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.downvotePodcast(podcastId: "5a63b51866503d002a70f809") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beTrue()) + expect(responseActive).toEventually(beTrue()) + } + + it("returns false when the podcast doesn't exist") { + // Setup + let path = Bundle(for: type(of: self)).path(forResource: "upvote_failure", ofType: "json")! + let data = NSData(contentsOfFile: path)! + self.stub(everything, jsonData(data as Data)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.downvotePodcast(podcastId: "") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + + it("returns false when the call fails") { + // Setup + let error = NSError(domain: "", code: 401, userInfo: nil) + self.stub(everything, failure(error)) + + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.downvotePodcast(podcastId: "") { (success, active) in + responseSuccess = success + responseActive = active + } + + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + + it("returns false when the downvote response object is not a dictionary") { + // Setup + self.stub(everything, json(["skl"])) + let api = API() + var responseSuccess: Bool? + var responseActive: Bool? + + api.downvotePodcast(podcastId: "5a63b51866503d002a70f809") { (success, active) in + responseSuccess = success + responseActive = active + + } + expect(responseSuccess).toEventually(beFalse()) + expect(responseActive).toEventually(beNil()) + } + } + } +} + diff --git a/SEDaily-IOSTests/HelpersTests.swift b/SEDaily-IOSTests/HelpersTests.swift new file mode 100644 index 0000000..ec9a0b7 --- /dev/null +++ b/SEDaily-IOSTests/HelpersTests.swift @@ -0,0 +1,68 @@ +// +// HelpersTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 25/11/2017. +// +// + +import XCTest +import Quick +import Nimble +@testable import SEDaily_IOS + +class HelpersTests: QuickSpec { + + override func spec() { + describe("isValidEmailAddress") { + it("accepts regular email address format") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test@example.com")).to(beTrue()) + } + + it("accepts dots and underscores") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test.exampl_e@example.com")).to(beTrue()) + } + + it("accepts letters and numbers") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test.exampl_e981@example.com")).to(beTrue()) + } + + it("accepts emails with multiple dots") { + expect(Helpers.isValidEmailAddress(emailAddressString: "bla@university.ac.uk")).to(beTrue()) + } + + it("doesn't accept empty string before the @ sign") { + expect(Helpers.isValidEmailAddress(emailAddressString: "@example.com")).to(beFalse()) + } + + it("doesn't accept characters other than dots and underscores in the main part of the address") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test.exam&pl_e981@example.com")).to(beFalse()) + } + + it("doesn't accept single characters after the final dot") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test@example.c")).to(beFalse()) + } + + it("doesn't accept numbers after the final dot") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test@example.coa3f")).to(beFalse()) + } + + it("doesn't accept symbols after the final dot") { + expect(Helpers.isValidEmailAddress(emailAddressString: "test@example.co/f")).to(beFalse()) + } + + } + + describe("getStringFrom tests") { + it("displays the value if the value is above 10") { + expect(Helpers.getStringFrom(seconds: 15)).to(equal("15")) + } + + it("displays the value with a leading zero if it's below 10") { + expect(Helpers.getStringFrom(seconds: 9)).to(equal("09")) + } + } + + } + +} diff --git a/SEDaily-IOSTests/Info.plist b/SEDaily-IOSTests/Info.plist index cb626a5..8a6f539 100644 --- a/SEDaily-IOSTests/Info.plist +++ b/SEDaily-IOSTests/Info.plist @@ -17,6 +17,6 @@ CFBundleShortVersionString 1.0 CFBundleVersion - 13 + 10 diff --git a/SEDaily-IOSTests/Mocks/Responses/login/login_nonexistinguser.json b/SEDaily-IOSTests/Mocks/Responses/login/login_nonexistinguser.json new file mode 100644 index 0000000..566415a --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/login/login_nonexistinguser.json @@ -0,0 +1 @@ +{"message":"User not found."} diff --git a/SEDaily-IOSTests/Mocks/Responses/login/login_success.json b/SEDaily-IOSTests/Mocks/Responses/login/login_success.json new file mode 100644 index 0000000..134fdbd --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/login/login_success.json @@ -0,0 +1 @@ +{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1YTVhOTgyZjdlNGY0MDAwMmEzMDEyZGEiLCJlbWFpbCI6ImJlcmt0ZXN0MTFAdGVzdC5jb20iLCJ1c2VybmFtZSI6ImJtIiwicGFzc3dvcmQiOiIkMmEkMDgkMUpRbnY5OGx2S2k4L0tiT29GVmk5dXM3YU5NME9WWTBHcHYwOFplM2E4YS4yWDlHd2pJdUsiLCJfX3YiOjAsImNyZWF0ZWRBdCI6IjIwMTgtMDEtMTNUMjM6Mzc6MTkuNTc2WiIsInZlcmlmaWVkIjpmYWxzZSwiaWF0IjoxNTE2MTMzNjEzLCJleHAiOjE2NjAxMzM2MTN9.5omwn1EzDPqyzfyMLcVbGdpaSlNAKvzwfQ_4HeEBdws"} diff --git a/SEDaily-IOSTests/Mocks/Responses/login/login_wrongpass.json b/SEDaily-IOSTests/Mocks/Responses/login/login_wrongpass.json new file mode 100644 index 0000000..310b618 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/login/login_wrongpass.json @@ -0,0 +1 @@ +{"message":"Password is incorrect."} diff --git a/SEDaily-IOSTests/Mocks/Responses/posts/getPostsWith_success.json b/SEDaily-IOSTests/Mocks/Responses/posts/getPostsWith_success.json new file mode 100644 index 0000000..16c763a --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/posts/getPostsWith_success.json @@ -0,0 +1,93 @@ + [ + { + "_id": "59df362f4a8a50462d2b475e", + "link": "https://softwareengineeringdaily.com/2017/10/12/blockchain-building-with-daniel-van-flymen/", + "categories": [ + 1363, + 1082, + 14 + ], + "tags": [ + 91, + 980, + 1532, + 1533, + 236 + ], + "mp3": "http://traffic.libsyn.com/sedaily/BuildaBlockchain.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/10/bitcoinminer.jpg", + "date": "2017-10-12T02:00:56.000Z", + "content": { + "rendered": "

\n

A blockchain is a data structure that provides decentralized, peer-to-peer data distribution. Bitcoin is the most well-known blockchain, but in the next decade we will see many more blockchains. Most listeners probably know that you could just fork the code of Bitcoin to start your own blockchain–but wouldn’t it be nice to know how to build a blockchain from scratch?

\n

Daniel van Flymen is the author of the Medium article Learn Blockchains by Building One. In his post, he walks you through how to write the code for a blockchain–just like any other web app. He starts with raw Python code, defines the data structures, and stands up his simple blockchain app on a web server to give a toy example for how nodes in a blockchain communicate.

\n

For me, this was a great article to read. I have reported on blockchains for over a year, but had not seen such a clear example with executable, simplified code.

\n

Stay tuned at the end of the episode for Jeff Meyerson’s tip about making the most of a new job: brought to you by Indeed Prime.

\n

To find all of our coverage of cryptocurrencies, download the Software Engineering Daily app for iOS or Android to hear all of our old episodes. They are easily organized by category, and as you listen, the SE Daily app gets smarter, and recommends you content based on the episodes you are hearing. If you don’t like this episode, you can easily find something more interesting by using the recommendation system.

\n

The mobile apps are open sourced at github.com/softwareengineeringdaily. If you are looking for an open source project to hack on, we would love to get your help! We are building a new way to consume software engineering content. We have the Android app, the iOS app, a recommendation system, and a web frontend–and more projects are coming soon. If you have ideas for how software engineering media content should be consumed, or if you are interested in contributing code, check out github.com/softwareengineeringdaily, or join our Slack channel (there’s a link on our website)–or send me an email: jeff@softwareengineeringdaily.com

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n

\"\"

\n
Spring Framework gives developers an environment for building cloud native projects. On December 4th-7th, SpringOne Platform is coming to San Francisco. SpringOne Platform is a conference where developers congregate to explore the latest technologies in the Spring ecosystem and beyond. Speakers at SpringOne Platform include Eric Brewer (who created the CAP theorem), Vaughn Vernon (who writes extensively about Domain Driven Design), and many thought leaders in the Spring Ecosystem. SpringOne Platform is the premier conference for those who build, deploy, and run cloud-native software. Software Engineering Daily listeners can sign up with the discount code SEDaily100 and receive $100 off of a Spring One Platform conference pass. I will also be at SpringOne reporting on developments in the cloud native ecosystem. Join me December 4th-7th at the SpringOne Platform conference, and use discount code SEDaily100 for $100 off your conference pass.

\n

\n
\n

\n


\n
Indeed Prime flips the typical model of job search and makes it easy to apply to multiple jobs and get multiple offers. Indeed Prime simplifies your job search and helps you land that ideal software engineering position. Candidates get immediate exposure to top companies with just one simple application to Indeed Prime. Companies on Prime’s exclusive platform message candidates with salary and equity upfront. Indeed Prime is 100% free for candidates – no strings attached. Sign up now at indeed.com/sedaily. You can also put money in your pocket by referring your friends and colleagues. Refer a software engineer to the platform and get $200 when they get contacted by a company…. and $2,000 when they accept a job through Prime! Learn more at indeed.com/prime/referral.

\n

\n
\n

\n

\"\"

\n
You want to work with Kubernetes but wish the process was simpler. The folks who brought you Kubernetes now want to make it easier to use. Heptio is a company by founders of the Kubernetes project, built to support and advance the open Kubernetes ecosystem. They build products, open source tools, and services that bring people closer to ‘upstream’ Kubernetes. Heptio offers instructor-led Kubernetes training, professional help from expert Kubernetes solutions engineers, as well as expert support of upstream Kubernetes configurations. Find out more at heptio.com/sedaily. Heptio is committed to making Kubernetes easier for all developers to use through their contributions to Kubernetes, Heptio open source projects, and other community efforts. Check out Heptio to make your life with Kubernetes easier at heptio.com/sedaily.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Blockchain Building with Daniel van Flymen" + }, + "score": 22, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "5913c07b4ee01db33caccea8", + "link": "https://softwareengineeringdaily.com/2017/04/06/blockchain-applications-with-mike-goldin/", + "categories": [ + 1082, + 14 + ], + "tags": [ + 91, + 1020, + 1019, + 106, + 98, + 1018 + ], + "mp3": "http://traffic.libsyn.com/sedaily/blockchainapps.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/04/Ethereum.jpg", + "date": "2017-04-06T08:00:53.000Z", + "content": { + "rendered": "

\n

Cryptocurrencies are not only a financial instrument–they are a new platform for building applications. The blockchain allows for new solutions to digital property management, micropayments, hedge fund incentives, and ad fraud.

\n

The cryptocurrency platforms with the most traction are Bitcoin and Ethereum. Bitcoin has no central leader and is going through some growing pains with governance issues. Ethereum is led by the charismatic Vitalik Buterin.

\n

Bitcoin and Ethereum are not competing instruments. They fulfill different technical purposes. Under current conditions of algorithm development and network infrastructure, neither Bitcoin nor Ethereum can accomplish the dreams that will one day be realized, because of the problem of distributing transaction information across nodes in the system.

\n

If we compared cryptocurrencies to the Internet, we would not even be in the days of dial-up yet.

\n

ConsenSys is a venture production studio that is working on several projects within the blockchain space. Mike Goldin is a software developer with ConsenSys and joins the show to talk about blockchain applications in 2017–where we are and where we are going. It was a wide ranging conversation and I hope to have Mike back in the future so we can go deeper on some of the topics we glossed over.

\n

Transcript

\n
Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view or download the transcript for this show.
\n

Sponsors

\n
\n


\n
Have you been thinking you’d be happier at a new job? If you’re dreaming about a new job and have been waiting for the right time to make a move, go to hired.com/sedaily. Hired makes finding work enjoyable. Hired uses an algorithmic job-matching tool in combination with a talent advocate who will walk you through the process of finding a better job. Check out hired.com/sedaily to get a special offer for Software Engineering Daily listeners–a $600 signing bonus from Hired when you find that great job that gives you the respect and salary that you deserve as a talented engineer.

\n

\n
\n

\n

\"\"

\n
Software engineers know that saving time means saving money. Save time on your accounting solution — use FreshBooks cloud accounting software. FreshBooks makes easy accounting software with a friendly UI that transforms how entrepreneurs and small business owners deal with day-to-day paperwork. Get ready for the simplest way to be more productive and organized, and most importantly, get paid quickly. FreshBooks is offering a 30-day, unrestricted free trial to Software Engineering Daily listeners. To claim it, just go to FreshBooks.com/SED and enter SOFTWARE ENGINEERING DAILY in the “How Did You Hear About Us?” section.

\n

\n
\n

\n

\"\"

\n
Incapsula can protect your API servers and microservices from responding to unwanted requests. To try Incapsula for yourself, go to incapsula.com/sedaily and get a month of Incapsula free. Incapsula’s API gives you control over the security and performance of your application–whether you have a complex microservices architecture or a WordPress site, like Software Engineering Daily. Incapsula has a global network of over 30 data centers that optimize routing and cache your content. The same network of data centers that are filtering your content for attackers are operating as a CDN, and speeding up your application. To try Incapsula today, go to incapsula.com/sedaily and check it out.

\n

\n
\n

 

\n", + "protected": false + }, + "title": { + "rendered": "Blockchain Applications with Mike Goldin" + }, + "score": 4, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "5913c0f04ee01db33cacd082", + "link": "https://softwareengineeringdaily.com/2015/08/11/blockchains-with-melanie-swan/", + "categories": [ + 1082, + 14 + ], + "tags": [ + 91, + 97, + 102, + 98, + 103, + 94, + 95 + ], + "mp3": "http://traffic.libsyn.com/sedaily/melanie_blockchains.mp3", + "featuredImage": "http://softwaredaily.wpengine.com/wp-content/uploads/2015/08/byzantine-generals-problem.png", + "date": "2015-08-12T02:00:39.000Z", + "content": { + "rendered": "

Blockchains are the distributed ledger technology underlying bitcoin and other cryptocurrencies.

\n

More broadly, a blockchain is a mechanism for updating truth states in distributed network computing, producing consensus trust and serving as a new form of general computational substrate.

\n

\"\"

\n

Melanie Swan is a science and technology innovator and philosopher at the MS Futures Group. She founded the Institute for Blockchain Studies, and is the author of Blockchain: Blueprint for a New Economy.

\n

Questions:

\n
    \n
  • What is a blockchain?
  • \n
  • How can we generalize the blockchain from bitcoin to other areas?
  • \n
  • How does every user maintain the entire transaction list when that list is gigantic and growing every day?
  • \n
  • How do Stellar and Ripple differ?
  • \n
  • What is Ethereum?
  • \n
  • Is there a social tension between having strong leaders like Vitalik Buterin and a decentralized ideal culture?
  • \n
  • Can you have a blockchain without mining?
  • \n
  • Can the blockchain be used to prevent the artificial intelligence nightmares of Elon Musk and Stephen Hawking?
  • \n
\n

Links:

\n\n", + "protected": false + }, + "title": { + "rendered": "Blockchains with Melanie Swan" + }, + "score": 2, + "bookmarked": false, + "upvoted": false, + "downvoted": false + } + ] diff --git a/SEDaily-IOSTests/Mocks/Responses/posts/getPosts_topPosts.json b/SEDaily-IOSTests/Mocks/Responses/posts/getPosts_topPosts.json new file mode 100644 index 0000000..1d75677 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/posts/getPosts_topPosts.json @@ -0,0 +1,304 @@ +[ + { + "_id": "59b9d4b60b3fde560aa0fbe7", + "link": "https://softwareengineeringdaily.com/2017/09/13/word2vec-with-adrian-colyer/", + "categories": [ + 1363, + 1080, + 14 + ], + "tags": [ + 1446, + 1445, + 311, + 1448, + 1447 + ], + "mp3": "http://traffic.libsyn.com/sedaily/Word2vecAdrianColyer.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/09/Word2vec.jpg", + "date": "2017-09-13T02:00:01.000Z", + "content": { + "rendered": "

\n

Machines understand the world through mathematical representations. In order to train a machine learning model, we need to describe everything in terms of numbers.  Images, words, and sounds are too abstract for a computer. But a series of numbers is a representation that we can all agree on, whether we are a computer or a human.

\n

In recent shows, we have explored how to train machine learning models to understand images and video. Today, we explore words. You might be thinking–”isn’t a word easy to understand? Can’t you just take the dictionary definition?” A dictionary definition does not capture the richness of a word. Dictionaries do not give you a way to measure similarity between one word and all other words in a given language.

\n

Word2vec is a system for defining words in terms of the words that appear close to that word. For example, the sentence “Howard is sitting in a Starbucks cafe drinking a cup of coffee” gives an obvious indication that the words “cafe,” “cup,” and “coffee” are all related. With enough sentences like that, we can start to understand the entire language.

\n

Adrian Colyer is a venture capitalist with Accel, and blogs about technical topics such as word2vec. We talked about word2vec specifically, and the deep learning space more generally. We also explored how the rapidly improving tools around deep learning are changing the venture investment landscape.

\n

If you like this episode, we have done many other shows about machine learning with guests like Matt Zeiler, the founder of Clarif.ai and Francois Chollet, the creator of Keras. You can check out our back catalog by downloading the Software Engineering Daily app for iOS, where you can listen to all of our old episodes, and easily discover new topics that might interest you. You can upvote the episodes you like and get recommendations based on your listening history. With 600 episodes, it is hard to find the episodes that appeal to you, and we hope the app helps with that.

\n

Question of the Week: What is your favorite continuous delivery or continuous integration tool? Email jeff@softwareengineeringdaily.com and a winner will be chosen at random to receive a Software Engineering Daily hoodie. 

\n

Sponsors

\n
\n

\"\"

\n
To build the kinds of things developers want to build today, they need better tools.  That’s why Amazon Web Services built Amazon Aurora. A relational database engine that’s compatible with MySQL and PostgreSQL, and provides up to five times the performance of standard MySQL—on the same hardware, at a tenth of the cost. Amazon Aurora from AWS can scale up to millions of transactions per minute. Automatically grow your storage up to 64 terabytes. And replicates data to three different Availability Zones. And you don’t have to manage a thing. There are no upfront charges, no commitments—you only pay for what you use. Check it out, at aurora.aws.

\n

\n
\n

\n

\"\"

\n
Toptal is the best place to find reasonably priced, extremely talented software engineers to build your projects from scratch or scale your workforce. Get a free pair of Apple Airpods when you use Toptal.com/sedaily to work with an engineer for at least 20 hours.

\n

\n
\n

\n

\"\"

\n
Cloudflare runs 10% of the Internet, providing performance and security to millions of websites. Many of you probably already use Cloudflare on your sites. We’re not talking about using Cloudflare today though, we’re here to talk about building on top of it. If you’re a developer you can build apps which can be installed by the the millions of sites which rely on Cloudflare. You can even sell your apps; they can make you money every month. Visit cloudflare.com/sedaily to watch how you can build and deploy an app in less than 3 minutes.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Word2Vec with Adrian Colyer" + }, + "score": 25, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59df362f4a8a50462d2b475e", + "link": "https://softwareengineeringdaily.com/2017/10/12/blockchain-building-with-daniel-van-flymen/", + "categories": [ + 1363, + 1082, + 14 + ], + "tags": [ + 91, + 980, + 1532, + 1533, + 236 + ], + "mp3": "http://traffic.libsyn.com/sedaily/BuildaBlockchain.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/10/bitcoinminer.jpg", + "date": "2017-10-12T02:00:56.000Z", + "content": { + "rendered": "

\n

A blockchain is a data structure that provides decentralized, peer-to-peer data distribution. Bitcoin is the most well-known blockchain, but in the next decade we will see many more blockchains. Most listeners probably know that you could just fork the code of Bitcoin to start your own blockchain–but wouldn’t it be nice to know how to build a blockchain from scratch?

\n

Daniel van Flymen is the author of the Medium article Learn Blockchains by Building One. In his post, he walks you through how to write the code for a blockchain–just like any other web app. He starts with raw Python code, defines the data structures, and stands up his simple blockchain app on a web server to give a toy example for how nodes in a blockchain communicate.

\n

For me, this was a great article to read. I have reported on blockchains for over a year, but had not seen such a clear example with executable, simplified code.

\n

Stay tuned at the end of the episode for Jeff Meyerson’s tip about making the most of a new job: brought to you by Indeed Prime.

\n

To find all of our coverage of cryptocurrencies, download the Software Engineering Daily app for iOS or Android to hear all of our old episodes. They are easily organized by category, and as you listen, the SE Daily app gets smarter, and recommends you content based on the episodes you are hearing. If you don’t like this episode, you can easily find something more interesting by using the recommendation system.

\n

The mobile apps are open sourced at github.com/softwareengineeringdaily. If you are looking for an open source project to hack on, we would love to get your help! We are building a new way to consume software engineering content. We have the Android app, the iOS app, a recommendation system, and a web frontend–and more projects are coming soon. If you have ideas for how software engineering media content should be consumed, or if you are interested in contributing code, check out github.com/softwareengineeringdaily, or join our Slack channel (there’s a link on our website)–or send me an email: jeff@softwareengineeringdaily.com

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n

\"\"

\n
Spring Framework gives developers an environment for building cloud native projects. On December 4th-7th, SpringOne Platform is coming to San Francisco. SpringOne Platform is a conference where developers congregate to explore the latest technologies in the Spring ecosystem and beyond. Speakers at SpringOne Platform include Eric Brewer (who created the CAP theorem), Vaughn Vernon (who writes extensively about Domain Driven Design), and many thought leaders in the Spring Ecosystem. SpringOne Platform is the premier conference for those who build, deploy, and run cloud-native software. Software Engineering Daily listeners can sign up with the discount code SEDaily100 and receive $100 off of a Spring One Platform conference pass. I will also be at SpringOne reporting on developments in the cloud native ecosystem. Join me December 4th-7th at the SpringOne Platform conference, and use discount code SEDaily100 for $100 off your conference pass.

\n

\n
\n

\n


\n
Indeed Prime flips the typical model of job search and makes it easy to apply to multiple jobs and get multiple offers. Indeed Prime simplifies your job search and helps you land that ideal software engineering position. Candidates get immediate exposure to top companies with just one simple application to Indeed Prime. Companies on Prime’s exclusive platform message candidates with salary and equity upfront. Indeed Prime is 100% free for candidates – no strings attached. Sign up now at indeed.com/sedaily. You can also put money in your pocket by referring your friends and colleagues. Refer a software engineer to the platform and get $200 when they get contacted by a company…. and $2,000 when they accept a job through Prime! Learn more at indeed.com/prime/referral.

\n

\n
\n

\n

\"\"

\n
You want to work with Kubernetes but wish the process was simpler. The folks who brought you Kubernetes now want to make it easier to use. Heptio is a company by founders of the Kubernetes project, built to support and advance the open Kubernetes ecosystem. They build products, open source tools, and services that bring people closer to ‘upstream’ Kubernetes. Heptio offers instructor-led Kubernetes training, professional help from expert Kubernetes solutions engineers, as well as expert support of upstream Kubernetes configurations. Find out more at heptio.com/sedaily. Heptio is committed to making Kubernetes easier for all developers to use through their contributions to Kubernetes, Heptio open source projects, and other community efforts. Check out Heptio to make your life with Kubernetes easier at heptio.com/sedaily.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Blockchain Building with Daniel van Flymen" + }, + "score": 22, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59fc372ca70af73494be12e7", + "link": "https://softwareengineeringdaily.com/2017/11/03/parlaying-failure-to-fortune-with-paul-martino/", + "categories": [ + 1363, + 1068, + 14 + ], + "tags": [ + 1608, + 1606, + 1605, + 1607, + 308 + ], + "mp3": "http://traffic.libsyn.com/sedaily/PaulMartino.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/11/bullpen-capital.jpg", + "date": "2017-11-03T02:00:21.000Z", + "content": { + "rendered": "

\n

In 2003, Paul Martino co-founded Tribe.net, one of the earliest social networking sites.  Tribe had significant traction, with hundreds of thousands of users.

\n

In the early 2000s, hundreds of thousands of users was enough traffic to pose a company with engineering challenges. Paul had studied computer science, and was able to use his knowledge of high-performance computing to write an efficient graph database, and solve the other technical puzzles that the company faced–but the business did not ultimately work out.

\n

The failure of Tribe made the founders even hungrier for success–and it taught them lessons that they carried into subsequent businesses.

\n

Paul went on to start Aggregate Knowledge, a marketing technology company that sold for $119 million. His Tribe co-founder Mark Pincus went on to start Zynga, the multi-billion dollar gaming company. Another Tribe employee co-founded Yammer, which sold to Microsoft for a billion dollars.

\n

Since his exit from Aggregate Knowledge, Paul Martino started Bullpen Capital, which makes post-seed investments. The Bullpen Capital portfolio is appealing to me–partly because of the number of Internet gambling companies. Paul and I talked about gambling and other taboo business sectors–as well as what makes a good investment in the “post-seed” category.

\n

I enjoyed speaking to Paul because he has a straightforward, no-nonsense way of talking about things–it’s very charismatic and uncommon.

\n

We have done some great shows with other engineering investors like Chris Dixon and Adrian Colyer. To find these old episodes, you can download the Software Engineering Daily app for iOS and for Android. In other podcast players, you can only access the most recent 100 episodes. With these apps, we are building a new way to consume content about software engineering. They are open-sourced at github.com/softwareengineeringdaily. If you are looking for an open source project to get involved with, we would love to get your help.

\n

Shout out to today’s featured contributor Kurian Vithayathil. He has made significant contributions to the Software Engineering Daily Android app. Thanks again Kurian for your work.

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n

\"\"

\n
Digital Ocean Spaces gives you simple object storage with a beautiful user interface. You need an easy way to host objects like images and videos. Your users need to upload objects like pdfs and music files. To try Digital Ocean Spaces, go to do.co/sedaily and get 2 months of Spaces plus a $10 credit to use on any other Digital Ocean products–and you get this credit even if you have been with Digital Ocean for awhile. It’s a nice added bonus just for trying out Spaces. If you become a customer, the pricing is simple:  $5 per month price and includes 250GB of storage and 1TB of outbound bandwidth. There are no costs per request and additional storage is priced at the lowest rate available: $0.01 per GB transferred and $0.02 per GB stored. There won’t be any surprises on your bill. Digital Ocean simplifies the cloud–they look for every opportunity to remove friction from a developer’s experience. I love it, and I think you will too–check it out at do.co/sedaily.

\n

\n
\n

\n

\"\"

\n
Spring Framework gives developers an environment for building cloud native projects. On December 4th-7th, SpringOne Platform is coming to San Francisco. SpringOne Platform is a conference where developers congregate to explore the latest technologies in the Spring ecosystem and beyond. Speakers at SpringOne Platform include Eric Brewer (who created the CAP theorem), Vaughn Vernon (who writes extensively about Domain Driven Design), and many thought leaders in the Spring Ecosystem. SpringOne Platform is the premier conference for those who build, deploy, and run cloud-native software. Software Engineering Daily listeners can sign up with the discount code SEDaily100 and receive $100 off of a Spring One Platform conference pass. I will also be at SpringOne reporting on developments in the cloud native ecosystem. Join me December 4th-7th at the SpringOne Platform conference, and use discount code SEDaily100 for $100 off your conference pass.

\n

\n
\n

\n

\"\"

\n
The octopus: a sea creature known for its intelligence and flexibility. Octopus Deploy: a friendly deployment automation tool for deploying applications like .NET apps, Java apps and more. Ask any developer and they’ll tell you it’s never fun pushing code at 5pm on a Friday then crossing your fingers hoping for the best. That’s where Octopus Deploy comes into the picture. Octopus Deploy is a friendly deployment automation tool, taking over where your build/CI server ends. Use Octopus to promote releases on-prem or to the cloud. Octopus integrates with your existing build pipeline–TFS and VSTS, Bamboo, TeamCity, and Jenkins. It integrates with AWS, Azure, and on-prem environments. Reliably and repeatedly deploy your .NET and Java apps and more. If you can package it, Octopus can deploy it! It’s quick and easy to install. Go to Octopus.com to trial Octopus free for 45 days. That’s Octopus.com\n

\n

\n
\n

\n

\"\"

\n
Incapsula can protect your API servers and microservices from responding to unwanted requests. To try Incapsula for yourself, go to incapsula.com/2017podcasts and get a free enterprise trial of Incapsula. Incapsula’s API gives you control over the security and performance of your application–whether you have a complex microservices architecture or a WordPress site, like Software Engineering Daily. Incapsula has a global network of over 30 data centers that optimize routing and cache your content. The same network of data centers that are filtering your content for attackers are operating as a CDN, and speeding up your application. To try Incapsula today, go to incapsula.com/2017podcasts and check it out. Thanks again, Incapsula.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Parlaying Failure to Fortune with Paul Martino" + }, + "score": 18, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59b321031e97db270a15f73e", + "link": "https://softwareengineeringdaily.com/2017/09/08/software-engineering-daily-app-with-keith-and-craig-holliday/", + "categories": [ + 1363, + 1078, + 14 + ], + "tags": [ + 1438, + 1437, + 1441, + 1440, + 1439 + ], + "mp3": "http://traffic.libsyn.com/sedaily/SEDApp.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/09/SED_Logo_315x909.png", + "date": "2017-09-08T02:00:15.000Z", + "content": { + "rendered": "

\n

You have probably missed some of the best episodes of Software Engineering Daily. If you listen to just a few episodes a week, it can be difficult to identify the high quality shows. And if you are new to the podcast, you have no idea how to find episodes that might appeal to you.

\n

Software Engineering Daily has a discovery problem.

\n

We have 600 episodes, and much of the content is evergreen. The shows we did a year ago on Apache Spark, or Ethereum, or ReactJS are still relevant today, and they get plenty of listens.

\n

Keith and Craig Holliday built a recommendation system for Software Engineering Daily. Then they built a Software Engineering Daily iOS app to improve the experience of SE Daily listeners. You can use the SE Daily app to find the most popular episodes of this podcast, and to find episode recommendations based on what you have listened to.

\n

In this episode, Keith and Craig join the show to explain why they built an app for Software Engineering Daily. You can find all the code for the SE Daily app at github.com/softwareengineeringdaily in case you want to fork it for your own podcast–or if you want to contribute to it.

\n

Question of the Week: What is your favorite continuous delivery or continuous integration tool? Email jeff@softwareengineeringdaily.com and a winner will be chosen at random to receive a Software Engineering Daily hoodie. 

\n

Sponsors

\n
\n

\"\"

\n
Cloudflare runs 10% of the Internet, providing performance and security to millions of websites. Many of you probably already use Cloudflare on your sites. We’re not talking about using Cloudflare today though, we’re here to talk about building on top of it. If you’re a developer you can build apps which can be installed by the the millions of sites which rely on Cloudflare. You can even sell your apps; they can make you money every month. Visit cloudflare.com/sedaily to watch how you can build and deploy an app in less than 3 minutes.

\n

\n
\n

\n


\n
Flip the traditional job search and let Indeed Prime work for you while you’re busy with other engineering work, or coding your side project. Upload your resume and in one click, gain immediate exposure to companies like Facebook, Uber, and Dropbox. Interested employers will reach out to you within one week with salary, position, and equity up front. Don’t let applying for jobs become a full-time job. With Indeed Prime, jobs come to you. The average software developer gets 5 employer contacts and an average salary offer of $125,000. Indeed Prime is 100% free for candidates – no strings attached. Sign up now at indeed.com/sedaily.

\n

\n
\n

\n

\"\"

\n
Thanks to Symphono for sponsoring Software Engineering Daily. Symphono is a custom engineering shop where senior engineers tackle big tech challenges while learning from each other. Check it out at symphono.com/sedaily. Thanks to Symphono for being a sponsor of Software Engineering Daily for almost a year now. Your continued support allows us to deliver content to the listeners on a regular basis.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Software Engineering Daily App with Keith and Craig Holliday" + }, + "score": 17, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59addb05f47a6d53a934cf55", + "link": "https://softwareengineeringdaily.com/2017/09/04/information-theory-with-jimmy-soni-and-rob-goodman/", + "categories": [ + 1363, + 1068, + 14 + ], + "tags": [ + 1424, + 1423, + 1427, + 1425, + 1426 + ], + "mp3": "http://traffic.libsyn.com/sedaily/ClaudeShannon.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/09/AMindatPlay.jpeg", + "date": "2017-09-04T02:00:49.000Z", + "content": { + "rendered": "

\n

We write code in a language that looks like English. Whether it is JavaScript, Fortran, or assembly language, that code is an abstraction on top of layers of intermediate languages, binary, transistors, and physics. 100 years ago, this would have seemed like magic.

\n

Most of us know about Alan Turing, who described the vision of a multipurpose computer with the concept of the Turing machine. Less well known is the scientist Claude Shannon, who laid the groundwork of information theory. With information theory, we can compress data and communicate it efficiently.

\n

Jimmy Soni and Rob Goodman are the authors of “A Mind at Play,” a biography of Claude Shannon. Claude’s unique insights about information were made possible by his willingness to involve himself in lots of different areas–science, art, juggling, warfare. This interview gives insights for how we can think of new ideas by synthesizing disparate subjects.

\n

There are 600 episodes of Software Engineering Daily, and it can be hard to find the shows that will interest you. If you have an iPhone and you listen to a lot of Software Engineering Daily, check out the Software Engineering Daily mobile app in the iOS App Store. Every episode can be accessed through the app, and we give you recommendations based on the ones you have already heard.

\n

Sponsors

\n
\n

\"\"

\n
To build the kinds of things developers want to build today, they need better tools.  That’s why Amazon Web Services built Amazon Aurora. A relational database engine that’s compatible with MySQL and PostgreSQL, and provides up to five times the performance of standard MySQL—on the same hardware, at a tenth of the cost. Amazon Aurora from AWS can scale up to millions of transactions per minute. Automatically grow your storage up to 64 terabytes. And replicates data to three different Availability Zones. And you don’t have to manage a thing. There are no upfront charges, no commitments—you only pay for what you use. Check it out, at aurora.aws.

\n

\n
\n

 

\n

\"\"

\n
GrammaTech CodeSonar helps development teams improve code quality with static analysis. It helps flag issues early in the development process, allowing developers to release better code faster. CodeSonar can easily be integrated into any development process. CodeSonar performs advanced static analysis of C, C++, Java, and even raw binary code. CodeSonar performs unique dataflow and symbolic execution analysis to aggressively scan for problems in your code. Just like battleships use sonar to detect objects deep underwater, engineers use CodeSonar to detect subtle problems deep within their code. Go to go.grammatech.com/sedaily to get your free 30-day trial, exclusively for Software Engineering Daily listeners and unleash the power of advanced static analysis.
\n

\n

\n
\n

\n

\"\"

\n
Who do you use for log management? I want to tell you about Scalyr, the first purpose built log management tool on the market. Most tools on the market utilize text indexing search, which is great… for indexing a book. But if you want to search logs, at scale, fast… it breaks down. Scalyr built their own database from scratch: the system is fast. Most searches take less than 1 second. In fact, 99% of their queries execute in <1 second.  Companies like OKCupid, Giphy and CareerBuilder use Scalyr. It was built by one of the founders of Writely (aka Google Docs). Scalyr has consumer grade UI, that scales infinitely. You can monitor key metrics, trigger alerts, and integrate with PagerDuty. It’s easy to use and did we mention: lightning fast. Give it a try today. It’s free for 90 days at scalyr.com/sedaily.

\n

\n
\n

\n

\n

\"\"

\n
Thanks to Symphono for sponsoring Software Engineering Daily. Symphono is a custom engineering shop where senior engineers tackle big tech challenges while learning from each other. Check it out at symphono.com/sedaily. Thanks to Symphono for being a sponsor of Software Engineering Daily for almost a year now. Your continued support allows us to deliver content to the listeners on a regular basis.

\n

\n
\n

 

\n", + "protected": false + }, + "title": { + "rendered": "Information Theory with Jimmy Soni and Rob Goodman" + }, + "score": 16, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59b731a5faa03a3fb5481329", + "link": "https://softwareengineeringdaily.com/2017/09/11/dao-hack-with-matt-leising/", + "categories": [ + 1363, + 1082, + 14 + ], + "tags": [ + 1234, + 980, + 1443, + 98, + 1442, + 1444 + ], + "mp3": "http://traffic.libsyn.com/sedaily/DAOHack.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/09/DAO.png", + "date": "2017-09-11T02:00:05.000Z", + "content": { + "rendered": "

\n

The Decentralized Autonomous Organization (DAO) was a digital form of venture capital. It was an ambitious idea–to provide a new decentralized business model for organizing corporations on top of the Ethereum blockchain. Few people in the crypto community were opposed to this premise–but the timeline was short, the code requirements were tremendous, and in retrospect, a vulnerability was inevitable.

\n

The DAO launched in May 2016, setting the record for the largest crowdfunding event in history. The following month, the DAO was hacked, millions of dollars of Ether were stolen, and the reverberations of the event were a referendum on how the Ethereum community governs itself.

\n

Matt Leising is a reporter for Bloomberg who has chronicled the DAO in his article The Ether Thief. He continues to follow cryptocurrencies closely, as the Internet of money fractals increasingly into the public consciousness.

\n

If you like this episode, we have done many other shows about cryptocurrencies and their implications. You can check out our back catalog by downloading the Software Engineering Daily app for iOS, where you can listen to all of our old episodes, and easily discover new topics that might interest you. You can upvote the episodes you like and get recommendations based on your listening history. With 600 episodes, it is hard to find the episodes that appeal to you, and we hope the app helps with that.

\n

Errata: Coinbase now supports Bitcoin Cash. 

\n

Sponsors

\n
\n

\"\"

\n
To build the kinds of things developers want to build today, they need better tools.  That’s why Amazon Web Services built Amazon Aurora. A relational database engine that’s compatible with MySQL and PostgreSQL, and provides up to five times the performance of standard MySQL—on the same hardware, at a tenth of the cost. Amazon Aurora from AWS can scale up to millions of transactions per minute. Automatically grow your storage up to 64 terabytes. And replicates data to three different Availability Zones. And you don’t have to manage a thing. There are no upfront charges, no commitments—you only pay for what you use. Check it out, at aurora.aws.

\n

\n
\n

\n

\"\"

\n
Toptal is the best place to find reasonably priced, extremely talented software engineers to build your projects from scratch or scale your workforce. Get a free pair of Apple Airpods when you use Toptal.com/sedaily to work with an engineer for at least 20 hours.

\n

\n
\n

\n

\"\"

\n
We know that monitoring can be a challenge…with so many services, apps, and containers to track, it’s harder than ever to understand application performance, and troubleshoot issues. Built by engineers, for engineers, Datadog is a platform that’s specifically designed to provide full-stack observability for modern applications. Datadog helps dev and ops teams easily see across all their servers, containers, apps, and services to monitor performance and make data-driven decisions. Datadog integrates seamlessly to gather metrics and events from more than 200 technologies, including AWS, Chef, Docker, and Redis. With built-in dashboards, algorithmic alerts, and end-to-end request tracing, Datadog helps teams monitor every layer of their stack in one place. But don’t take our word for it—start a free trial today & Datadog will send you a free T-shirt! Visit softwareengineeringdaily.com/datadog to get started. 

\n

\n
\n

\n

\"\"

\n
If you want to start a podcast, check out Podsheets. Podsheets is a product we built to create and manage podcasts. We are podcasters ourselves–and we understand the difficulties of getting started. Podsheets makes it easy to post your episodes and distribute them to iTunes and Google Play with a single click. If you are curious about podcasting, but have no idea where to start, Podsheets will guide you through the process. With Software Engineering Daily, we have been producing 5 shows a week for 2 years. We understand recording, we understand how to produce your show and we understand how to get advertisers. We want to help you with this process. Check out Podsheets today. We will give you everything you need to create and manage your podcast–and if you have any questions or you get confused, you can always contact us directly for help. Podcasting is as easy as blogging–let us show you how to podcast, with Podsheets.\n

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "DAO Hack with Matt Leising" + }, + "score": 16, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59c4473ff1bdff59dd0527f8", + "link": "https://softwareengineeringdaily.com/2017/09/21/tinder-growth-engineering-with-alex-ross/", + "categories": [ + 1363, + 1079, + 14 + ], + "tags": [ + 1470, + 1474, + 1473, + 1472, + 1471, + 1475 + ], + "mp3": "http://traffic.libsyn.com/sedaily/TinderGrowthEngineering.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/09/tinder.jpg", + "date": "2017-09-21T02:00:38.000Z", + "content": { + "rendered": "

\n

Tinder is a popular dating app where each user swipes through a sequence of other users in order to find a match. Swiping left means you are not interested. Swiping right means you would like to connect with the person. The simple premise of Tinder has led to massive growth, and the app is now also used to discover new friends and create casual meetings.

\n

Every social network knows–if you are not growing, then you are dying. Growth is so important to Tinder, they have a large engineering organization devoted to five facets of growth: new users, activation, retention, dropoff, and anti-spam.

\n

These five segments cover the entire Tinder user lifecycle, and there is a sub-team in charge of each of the five areas. No matter what kind of Tinder user you are, there are growth engineers focused on your experience.

\n

Alex Ross is the director of engineering for the growth team at Tinder. His job requires a mix of data science, data engineering, psychology, and setting proper KPIs (key performance indicators). Each subteam has KPIs that determine how well they are doing with growth–and if the wrong KPI is set, it can create bad incentives. For example, a growth team that is focused only on getting users to spend more time engaging with Tinder would have an incentive to create so-called “dark patterns” that trigger addiction.

\n

If you like this episode, we have done many other shows about data science and data engineering. Download the Software Engineering Daily app for iOS to hear all of our old episodes, and easily discover new topics that might interest you. You can upvote the episodes you like and get recommendations based on your listening history. With 600 episodes, it is hard to find the episodes that appeal to you, and we hope the app helps with that.

\n

Sponsors

\n
\n

\"\"

\n
Toptal is the best place to find reasonably priced, extremely talented software engineers to build your projects from scratch or scale your workforce. Get a free pair of Apple Airpods when you use Toptal.com/sedaily to work with an engineer for at least 20 hours.

\n

\n
\n

\n

\n


\n
Flip the traditional job search and let Indeed Prime work for you while you’re busy with other engineering work, or coding your side project. Upload your resume and in one click, gain immediate exposure to companies like Facebook, Uber, and Dropbox. Interested employers will reach out to you within one week with salary, position, and equity up front. Don’t let applying for jobs become a full-time job. With Indeed Prime, jobs come to you. The average software developer gets 5 employer contacts and an average salary offer of $125,000. Indeed Prime is 100% free for candidates – no strings attached. Sign up now at indeed.com/sedaily.

\n

\n
\n

\n

\"\"

\n
Amazon Redshift powers the analytics of your business–and Intermix.io powers the analytics of your Redshift. Intermix.io gives you the tools you need to analyze your Amazon Redshift performance and improve the toolchain of everyone downstream from your data warehouse. The team at Intermix has seen so many Redshift clusters, they are confident they can solve whatever performance issues you are having. Go to intermix.io/sedaily to get a free 30-day trial. Intermix collects all your Redshift logs and makes it easy to figure out what’s wrong so you can take action. All in a nice, intuitive dashboard. Go to intermix.io/sedaily to start your free 30-day trial.

\n

\n
\n

\n

\"\"

\n
GrammaTech CodeSonar helps development teams improve code quality with static analysis. It helps flag issues early in the development process, allowing developers to release better code faster. CodeSonar can easily be integrated into any development process. CodeSonar performs advanced static analysis of C, C++, Java, and even raw binary code. CodeSonar performs unique dataflow and symbolic execution analysis to aggressively scan for problems in your code. Just like battleships use sonar to detect objects deep underwater, engineers use CodeSonar to detect subtle problems deep within their code. Go to go.grammatech.com/sedaily to get your free 30-day trial, exclusively for Software Engineering Daily listeners and unleash the power of advanced static analysis.
\n

\n

\n
\n

 

\n", + "protected": false + }, + "title": { + "rendered": "Tinder Growth Engineering with Alex Ross" + }, + "score": 15, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "5913c07a4ee01db33cacce94", + "link": "https://softwareengineeringdaily.com/2017/05/02/data-intensive-applications-with-martin-kleppmann/", + "categories": [ + 1081, + 14 + ], + "tags": [ + 447, + 1101, + 1100, + 1102, + 353 + ], + "mp3": "http://traffic.libsyn.com/sedaily/dataintensive_edited_fixed.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/05/dataintensive_cover.png", + "date": "2017-05-02T08:00:02.000Z", + "content": { + "rendered": "

\n

A new programmer learns to build applications using data structures like a queue, a cache, or a database. Modern cloud applications are built using more sophisticated tools like Redis, Kafka, or Amazon S3. These tools do multiple things well, and often have overlapping functionality. Application architecture becomes less straightforward.

\n

The applications we are building today are data-intensive rather than compute-intensive. Netflix needs to know how to store and cache large video files, and stream them to users quickly. Twitter needs to update user news feeds with a fanout of the president’s latest tweet. These operations are simple with small amounts of data, but become complicated with a high volume of users.

\n

Martin Kleppmann is the author of Data Intensive Applications, an O’Reilly book about how to use modern data tools to solve modern data problems. His book includes high-level discussions about architectural strategy, and lower level discussions like how leader election algorithms can create problems for a data intensive application.

\n

If you are interested in hosting a show for Software Engineering Daily, we are looking for engineers, journalists, and hackers who want to work with us on content. It is a paid opportunity. Go to softwareengineeringdaily.com/host to find out more.

\n

The Software Engineering Daily store is now open if you want to buy a Software Engineering Daily branded t-shirt, hoodie, or mug and support the show. 

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n

\"\"

\n
Software engineers know that saving time means saving money. Save time on your accounting solution — use FreshBooks cloud accounting software. FreshBooks makes easy accounting software with a friendly UI that transforms how entrepreneurs and small business owners deal with day-to-day paperwork. Get ready for the simplest way to be more productive and organized, and most importantly, get paid quickly. FreshBooks is offering a 30-day, unrestricted free trial to Software Engineering Daily listeners. To claim it, just go to FreshBooks.com/SED and enter SOFTWARE ENGINEERING DAILY in the “How Did You Hear About Us?” section.

\n

\n
\n

\n


\n
Don’t let your database be a black box–drill down into the metrics of your database with 1-second granularity. VividCortex provides database monitoring for MySQL, Postgres, Redis, MongoDB, and Amazon Aurora. Database uptime, efficiency, and performance can all be measured using VividCortex. VividCortex uses patented algorithms to analyze and surface relevant insights, so users can be proactive, and fix performance problems before customers are impacted. If you have a database that you would like to monitor more closely, check out vividcortex.com/sedaily. Github, DigitalOcean, and Yelp all use VividCortex to understand database performance. Learn more at vividcortex.com/sedaily, and request a demo!

\n

\n
\n

\n

\"\"

\n
Oracle Dyn provides DNS that is as dynamic and intelligent as your applications. Dyn DNS gets your users to the right cloud service, CDN, or data center, using intelligent response to steer traffic based on business policies, as well as real-time internet conditions, like the security and performance of the network path. Get started with a free 30-day trial for your application by going to dyn.com/sedaily.  After the free trial, Dyn’s developer plans start at just $7 a month for world-class DNS. Rethink DNS. Go to dyn.com/sedaily to learn more and get your free trial of Dyn DNS.

\n

\n
\n", + "protected": false + }, + "title": { + "rendered": "Data Intensive Applications with Martin Kleppmann" + }, + "score": 14, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59dc933382eb5a1865040242", + "link": "https://softwareengineeringdaily.com/2017/10/10/bitcoin-segwit-with-jordan-clifford/", + "categories": [ + 1363, + 1082, + 14 + ], + "tags": [ + 1529, + 97, + 980, + 255, + 1278, + 1530 + ], + "mp3": "http://traffic.libsyn.com/sedaily/BlocksizeDebate.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/10/BitcoinSegwit.jpg", + "date": "2017-10-10T02:00:40.000Z", + "content": { + "rendered": "

\n

Visa processes 1,600 transactions per second. PayPal processes 193 transactions per second. Bitcoin processes only 3-4 transactions per second. In order to fulfill the dreams of financial programming–in order to get decentralized, peer-to-peer micropayments–Bitcoin needs a much higher transaction throughput. Bitcoin’s scalability issues have led to debates within the community and changes in the software.

\n

In this episode, Jordan Clifford gives an overview of some of the scaling limitations of Bitcoin, and discusses SegWit, a change to the Bitcoin protocol that improves scalability. Jordan was previously on the show to discuss the basics of Ethereum and Bitcoin. This episode covers some advanced topics of Bitcoin, and if you are out of your comfort zone, don’t worry–you aren’t alone.

\n

Stay tuned at the end of the episode for Jeff Meyerson’s tip about assessing cultural fit at a company: brought to you by Indeed Prime.

\n

We have covered the basics of cryptocurrencies in detail, and we have also tackled more complex aspects of them in past episodes. Download the Software Engineering Daily app for iOS and Android to hear all of our old episodes. They are easily organized by category, and as you listen, the SE Daily app gets smarter, and recommends you content based on the episodes you are hearing. If you don’t like this episode, you can easily find something more interesting by using the recommendation system.

\n

The mobile apps are open sourced at github.com/softwareengineeringdaily. If you are looking for an open source project to hack on, we would love to get your help! We are building a new way to consume software engineering content. We have the Android app, the iOS app, a recommendation system, and a web frontend–and more projects are coming soon. If you have ideas for how software engineering media content should be consumed, or if you are interested in contributing code, check out github.com/softwareengineeringdaily, or join our Slack channel (there’s a link on our website)–or send me an email: jeff@softwareengineeringdaily.com

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n


\n
Indeed Prime flips the typical model of job search and makes it easy to apply to multiple jobs and get multiple offers. Indeed Prime simplifies your job search and helps you land that ideal software engineering position. Candidates get immediate exposure to top companies with just one simple application to Indeed Prime. Companies on Prime’s exclusive platform message candidates with salary and equity upfront. Indeed Prime is 100% free for candidates – no strings attached. Sign up now at indeed.com/sedaily. You can also put money in your pocket by referring your friends and colleagues. Refer a software engineer to the platform and get $200 when they get contacted by a company…. and $2,000 when they accept a job through Prime! Learn more at indeed.com/prime/referral.

\n

\n
\n

\n

\"\"

\n
When your application is failing on a user’s device, how do you find out about it? Raygun lets you see every problem in your software and how to fix it. Raygun brings together crash reporting, real user monitoring, user tracking, and deployment tracking. See every error and crash affecting your users right now. Monitor your deployments, to make sure that a release is not impacting users in new ways. And track your users through your application to identify the bad experiences they are having. Go to softwareengineeringdaily.com/raygun, and get a free 14 day trial to try out Raygun and find the errors that are occurring in your applications today. Raygun is used by Microsoft, Slack, and Unity to monitor their customer facing software. Go to softwareengineeringdaily.com/raygun and try it out for yourself.
\n

\n

\n
\n

\n

\"\"

\n
Every second your cloud servers are running, they are costing you money. Stop paying for idle cloud instances and VMs. Control the cost of your cloud with ParkMyCloud. ParkMyCloud automatically turns off cloud resources when you don’t need them. Whether you are on AWS, Azure, or Google Cloud, it’s easy to start saving money with ParkMyCloud. You sign up for ParkMyCloud, you connect to your cloud provider, and ParkMyCloud gives you a dashboard of all your resources–including their costs. From the dashboard, you can automatically schedule when your different cloud instances get turned on or off – saving you 65% or more. Additionally, you can manage databases, auto scaling groups and set up logical groups of servers to turn off during nights and weekends when you don’t need them–and you can see how much money you are saving. Go to parkmycloud.com/sedaily to get $100 in free credit for ParkMyCloud for SE Daily listeners. ParkMyCloud is used by McDonald’s, CapitalOne, and Fox, and saves customers tens of thousands of dollars every month. Go to parkmycloud.com/sedaily, and cut the cost of your cloud today.

\n

\n
\n

 

\n

 

\n", + "protected": false + }, + "title": { + "rendered": "Bitcoin Segwit with Jordan Clifford" + }, + "score": 14, + "bookmarked": false, + "upvoted": false, + "downvoted": false + }, + { + "_id": "59dde4b285aeb72dae0daee0", + "link": "https://softwareengineeringdaily.com/2017/10/11/ethereum-platform-with-preethi-kasireddy/", + "categories": [ + 1363, + 1082, + 14 + ], + "tags": [ + 97, + 980, + 98, + 1046, + 1531 + ], + "mp3": "http://traffic.libsyn.com/sedaily/EthereumBasics.mp3", + "featuredImage": "http://softwareengineeringdaily.com/wp-content/uploads/2017/10/ethereum.jpg", + "date": "2017-10-11T02:00:39.000Z", + "content": { + "rendered": "

\n

Ethereum is a decentralized transaction-based state machine. Ethereum was designed to make smart contracts more usable for developers. Smart contracts are decentralized programs that usually allow for some a transaction between the owner of the contract and anyone who would want to purchase something from the contract owner.

\n

For example, I could set up a smart contract where a listener sends my smart contract some ether and I send the listener a podcast episode automatically. Smart contracts can also interact with each other, to network together complex transactions. In the same way that web development has been made easier by PaaS and SaaS, smart contracts will make building financial systems simple.

\n

Preethi Kasireddy is a blockchain developer who writes extensively about cryptocurrencies. She joins the show to describe how the Ethereum platform works, including the steps involved in a smart contract transaction. This episode covers some advanced topics of Ethereum, and if you are out of your comfort zone, don’t worry–you aren’t alone.

\n

We have covered the basics of cryptocurrencies in detail, and we have also tackled more complex aspects of them in past episodes. Download the Software Engineering Daily app for iOS and Android to hear all of our old episodes. They are easily organized by category, and as you listen, the SE Daily app gets smarter, and recommends you content based on the episodes you are hearing. If you don’t like this episode, you can easily find something more interesting by using the recommendation system.

\n

The mobile apps are open sourced at github.com/softwareengineeringdaily. If you are looking for an open source project to hack on, we would love to get your help! We are building a new way to consume software engineering content. We have the Android app, the iOS app, a recommendation system, and a web frontend–and more projects are coming soon. If you have ideas for how software engineering media content should be consumed, or if you are interested in contributing code, check out github.com/softwareengineeringdaily, or join our Slack channel (there’s a link on our website)–or send me an email: jeff@softwareengineeringdaily.com

\n

Transcript

\n

Transcript provided by We Edit Podcasts. Software Engineering Daily listeners can go to weeditpodcasts.com/sed to get 20% off the first two months of audio editing and transcription services. Thanks to We Edit Podcasts for partnering with SE Daily. Please click here to view this show’s transcript.

\n

Sponsors

\n
\n

\"\"

\n
The octopus: a sea creature known for its intelligence and flexibility. Octopus Deploy: a friendly deployment automation tool for deploying applications like .NET apps, Java apps and more. Ask any developer and they’ll tell you it’s never fun pushing code at 5pm on a Friday then crossing your fingers hoping for the best. That’s where Octopus Deploy comes into the picture. Octopus Deploy is a friendly deployment automation tool, taking over where your build/CI server ends. Use Octopus to promote releases on-prem or to the cloud. Octopus integrates with your existing build pipeline–TFS and VSTS, Bamboo, TeamCity, and Jenkins. It integrates with AWS, Azure, and on-prem environments. Reliably and repeatedly deploy your .NET and Java apps and more. If you can package it, Octopus can deploy it! It’s quick and easy to install. Go to Octopus.com to trial Octopus free for 45 days. That’s Octopus.com\n

\n

\n
\n

\n

\"\"

\n
Every second your cloud servers are running, they are costing you money. Stop paying for idle cloud instances and VMs. Control the cost of your cloud with ParkMyCloud. ParkMyCloud automatically turns off cloud resources when you don’t need them. Whether you are on AWS, Azure, or Google Cloud, it’s easy to start saving money with ParkMyCloud. You sign up for ParkMyCloud, you connect to your cloud provider, and ParkMyCloud gives you a dashboard of all your resources–including their costs. From the dashboard, you can automatically schedule when your different cloud instances get turned on or off – saving you 65% or more. Additionally, you can manage databases, auto scaling groups and set up logical groups of servers to turn off during nights and weekends when you don’t need them–and you can see how much money you are saving. Go to parkmycloud.com/sedaily to get $100 in free credit for ParkMyCloud for SE Daily listeners. ParkMyCloud is used by McDonald’s, CapitalOne, and Fox, and saves customers tens of thousands of dollars every month. Go to parkmycloud.com/sedaily, and cut the cost of your cloud today.

\n

\n
\n

\n

\"\"

\n
Who do you use for log management? I want to tell you about Scalyr, the first purpose built log management tool on the market. Most tools on the market utilize text indexing search, which is great… for indexing a book. But if you want to search logs, at scale, fast… it breaks down. Scalyr built their own database from scratch: the system is fast. Most searches take less than 1 second. In fact, 99% of their queries execute in <1 second.  Companies like OKCupid, Giphy and CareerBuilder use Scalyr. It was built by one of the founders of Writely (aka Google Docs). Scalyr has consumer grade UI, that scales infinitely. You can monitor key metrics, trigger alerts, and integrate with PagerDuty. It’s easy to use and did we mention: lightning fast. Give it a try today. It’s free for 90 days at softwareengineeringdaily.com/scalyr.

\n

\n
\n

\n

\"\"

\n
Thanks to Symphono for sponsoring Software Engineering Daily. Symphono is a custom engineering shop where senior engineers tackle big tech challenges while learning from each other. Check it out at symphono.com/sedaily. Thanks to Symphono for being a sponsor of Software Engineering Daily for almost a year now. Your continued support allows us to deliver content to the listeners on a regular basis.

\n

\n
\n

 

\n

 

\n

 

\n", + "protected": false + }, + "title": { + "rendered": "Ethereum Platform with Preethi Kasireddy" + }, + "score": 14, + "bookmarked": false, + "upvoted": false, + "downvoted": false + } + ] diff --git a/SEDaily-IOSTests/Mocks/Responses/posts/upvote_failure.json b/SEDaily-IOSTests/Mocks/Responses/posts/upvote_failure.json new file mode 100644 index 0000000..086c657 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/posts/upvote_failure.json @@ -0,0 +1,4 @@ +{ + "message": "Not Found", + "stack": {} +} diff --git a/SEDaily-IOSTests/Mocks/Responses/posts/upvote_success.json b/SEDaily-IOSTests/Mocks/Responses/posts/upvote_success.json new file mode 100644 index 0000000..f2ffc66 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/posts/upvote_success.json @@ -0,0 +1,9 @@ +{ + "_id": "5a63b51866503d002a70f809", + "direction": "upvote", + "userId": "5a5a982f7e4f40002a3012da", + "postId": "59df362f4a8a50462d2b475e", + "entityId": "59df362f4a8a50462d2b475e", + "__v": 0, + "active": true +} diff --git a/SEDaily-IOSTests/Mocks/Responses/register/register_emptyusernamepass.json b/SEDaily-IOSTests/Mocks/Responses/register/register_emptyusernamepass.json new file mode 100644 index 0000000..1eecf61 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/register/register_emptyusernamepass.json @@ -0,0 +1,4 @@ +{ + "message": "\"username\" is not allowed to be empty and \"password\" is not allowed to be empty", + "stack": {} +} diff --git a/SEDaily-IOSTests/Mocks/Responses/register/register_success.json b/SEDaily-IOSTests/Mocks/Responses/register/register_success.json new file mode 100644 index 0000000..dac5d69 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/register/register_success.json @@ -0,0 +1,11 @@ +{ + "user": { + "__v": 0, + "username": "testuser@test.com", + "password": "$2a$08$PmyX0n5VMY8zVte7.dDGMOdSIP1oU7FfBWN9uCukjp1.V4ryFqtmW", + "_id": "5a60ff9de29fa3002a639e5a", + "createdAt": "2018-01-18T20:12:13.358Z", + "verified": false + }, + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfX3YiOjAsInVzZXJuYW1lIjoiYmVya3Rlc3QxMTExMUB0ZXN0LmNvbSIsInBhc3N3b3JkIjoiJDJhJDA4JFBteVgwbjVWTVk4elZ0ZTcuZERHTU9kU0lQMW9VN0ZmQldOOXVDdWtqcDEuVjRyeUZxdG1XIiwiX2lkIjoiNWE2MGZmOWRlMjlmYTMwMDJhNjM5ZTVhIiwiY3JlYXRlZEF0IjoiMjAxOC0wMS0xOFQyMDoxMjoxMy4zNThaIiwidmVyaWZpZWQiOmZhbHNlLCJpYXQiOjE1MTYzMDYzMzMsImV4cCI6MTY2MDMwNjMzM30.uk4ixawSQ11vcS2Cvw3MoxlTvwNlCBnu8MUzE9QgqQM" +} diff --git a/SEDaily-IOSTests/Mocks/Responses/register/register_userexists.json b/SEDaily-IOSTests/Mocks/Responses/register/register_userexists.json new file mode 100644 index 0000000..087f41b --- /dev/null +++ b/SEDaily-IOSTests/Mocks/Responses/register/register_userexists.json @@ -0,0 +1,4 @@ +{ + "message": "User already exists.", + "stack": {} +} diff --git a/SEDaily-IOSTests/Mocks/UserDefaultsMock.swift b/SEDaily-IOSTests/Mocks/UserDefaultsMock.swift new file mode 100644 index 0000000..bf402a7 --- /dev/null +++ b/SEDaily-IOSTests/Mocks/UserDefaultsMock.swift @@ -0,0 +1,33 @@ +// +// UserDefaultsMock.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 26/11/2017. +// +// + +import Foundation +@testable import SEDaily_IOS + +class UserDefaultsMock: UserDefaultsProtocol { + + var dict: [String: Any] + + init(dict: [String: Any]) { + self.dict = dict + } + + func set(_ value: Any?, forKey defaultName: String) { + if let validValue = value { + dict[defaultName] = validValue + } + } + + func data(forKey defaultName: String) -> Data? { + let dataObj = dict[defaultName] as? Data + return dataObj + } + +} + + diff --git a/SEDaily-IOSTests/SEDaily_IOSTests.swift b/SEDaily-IOSTests/SEDailyIOSTests.swift similarity index 71% rename from SEDaily-IOSTests/SEDaily_IOSTests.swift rename to SEDaily-IOSTests/SEDailyIOSTests.swift index cfac8b5..3760fdc 100644 --- a/SEDaily-IOSTests/SEDaily_IOSTests.swift +++ b/SEDaily-IOSTests/SEDailyIOSTests.swift @@ -7,32 +7,23 @@ // import XCTest -import SwiftSoup -import Atributika @testable import SEDaily_IOS -class SEDaily_IOSTests: XCTestCase { - +class SEDailyIOSTests: XCTestCase { + override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } - + override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } - + func testExample() { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. } - - func testPerformanceExample() { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - + } diff --git a/SEDaily-IOSTests/UserModelTests.swift b/SEDaily-IOSTests/UserModelTests.swift new file mode 100644 index 0000000..c376265 --- /dev/null +++ b/SEDaily-IOSTests/UserModelTests.swift @@ -0,0 +1,93 @@ +// +// UserModelTests.swift +// SEDaily-IOSTests +// +// Created by Berk Mollamustafaoglu on 26/11/2017. +// Copyright © 2017 Koala Tea. All rights reserved. +// + +import XCTest +import Quick +import Nimble +@testable import SEDaily_IOS + +class UserModelTests: QuickSpec { + + override func spec() { + describe("userModel tests") { + var userManager: UserManager! + var user: User! + var encoder = JSONEncoder() + var decoder = JSONDecoder() + + beforeEach { + let dict = [String:Any]() + userManager = UserManager(userDefaults: UserDefaultsMock(dict: dict)) + + user = User(firstName: "firstName", + lastName: "lastName", + usernameOrEmail: "email@email.com", + token: "abcdefg") + userManager.setCurrentUser(to: user) + } + + describe("isCurrentUserLoggedIn tests") { + it("returns false when user is not logged in") { + userManager.setCurrentUser(to: User()) + expect(userManager.isCurrentUserLoggedIn()).to(beFalse()) + } + it("returns true when user is logged in") { + expect(userManager.isCurrentUserLoggedIn()).to(beTrue()) + } + } + + describe("getActiveUser tests") { + it("returns the active user when the saved user matches the current user") { + expect(userManager.getActiveUser()).to(equal(user)) + } + + it("returns the current user rather than stale value from defaults if they differ") { + userManager.defaults.set(User(), forKey: "user") + expect(userManager.getActiveUser()).to(equal(user)) + } + } + + describe("logout user") { + + it("sets current user to empty") { + userManager.logoutUser() + expect(userManager.currentUser).to(equal(User())) + } + } + + describe("setCurrentUser tests") { + + it("changes user if the value passed in is a different user") { + let newUser = User(firstName: "firstName", + lastName: "lastName", + usernameOrEmail: "email@email.com", + token: "abcdefg") + + userManager.setCurrentUser(to: newUser) + expect(userManager.currentUser).to(equal(newUser)) + } + + it("doesn't change user if the same user is passed in") { + let localUser = User(firstName: "firstName", + lastName: "lastName", + usernameOrEmail: "email@email.com", + token: "abcdefg") + + userManager.setCurrentUser(to: localUser) + let sameUser = User(firstName: "firstName", + lastName: "lastName", + usernameOrEmail: "email@email.com", + token: "abcdefg") + + userManager.setCurrentUser(to: sameUser) + expect(userManager.currentUser).to(equal(localUser)) + } + } + } + } +} diff --git a/fastlane/README.md b/fastlane/README.md index b402dfb..ac527a2 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -8,25 +8,11 @@ Make sure you have the latest version of the Xcode command line tools installed: xcode-select --install ``` -## Choose your installation method: - - - - - - - - - - - - - - -
Homebrew -Installer Script -RubyGems -
macOSmacOSmacOS or Linux with Ruby 2.0.0 or above
brew cask install fastlaneDownload the zip file. Then double click on the install script (or run it in a terminal window).sudo gem install fastlane -NV
+Install _fastlane_ using +``` +[sudo] gem install fastlane -NV +``` +or alternatively using `brew cask install fastlane` # Available Actions ## iOS diff --git a/screenshots/app_screenshots.png b/screenshots/app_screenshots.png new file mode 100644 index 0000000..49d9dfe Binary files /dev/null and b/screenshots/app_screenshots.png differ