├── .gitignore ├── LICENSE.txt ├── Ookami.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcuserdata │ └── Maka.xcuserdatad │ └── xcschemes │ ├── Ookami.xcscheme │ ├── OokamiKit.xcscheme │ └── xcschememanagement.plist ├── Ookami.xcworkspace └── contents.xcworkspacedata ├── Ookami ├── AppCoordinator.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── 1password.imageset │ │ ├── Contents.json │ │ ├── onepassword-button.png │ │ ├── onepassword-button@2x.png │ │ └── onepassword-button@3x.png │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-60@2x.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-72.png │ │ ├── Icon-72@2x.png │ │ ├── Icon-76.png │ │ ├── Icon-76@2x.png │ │ ├── Icon-83.5@2x.png │ │ ├── Icon-Small-40.png │ │ ├── Icon-Small-40@2x.png │ │ ├── Icon-Small-40@3x.png │ │ ├── Icon-Small-50.png │ │ ├── Icon-Small-50@2x.png │ │ ├── Icon-Small.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── Icon.png │ │ ├── Icon@2x.png │ │ └── iTunesArtwork@2x.png │ ├── Book_tab_bar.imageset │ │ ├── Contents.json │ │ └── book.pdf │ ├── Check.imageset │ │ ├── Contents.json │ │ └── check.pdf │ ├── Close.imageset │ │ ├── Contents.json │ │ └── close.pdf │ ├── Contents.json │ ├── Search_tab_bar.imageset │ │ ├── Contents.json │ │ └── Search-ios.pdf │ └── Trending_tab_bar.imageset │ │ ├── Bar.pdf │ │ └── Contents.json ├── Discover │ ├── Data Sources │ │ ├── AnimeDiscoverDataSource.swift │ │ ├── MangaDiscoverDataSource.swift │ │ └── MediaDiscoverDataSource.swift │ ├── Filters │ │ ├── AnimeFilterViewController.swift │ │ ├── BaseMediaFilterViewController.swift │ │ ├── DiscoverFilterHelper.swift │ │ └── MangaFilterViewController.swift │ └── MediaDiscoverViewController.swift ├── Extenstions │ ├── Double+Ookami.swift │ └── UIImage+Ookami.swift ├── Library Entry │ ├── Cells │ │ ├── EntryBoolTableViewCell.swift │ │ ├── EntryBoolTableViewCell.xib │ │ ├── EntryButtonTableViewCell.swift │ │ ├── EntryButtonTableViewCell.xib │ │ ├── EntryStringTableViewCell.swift │ │ └── EntryStringTableViewCell.xib │ ├── Data Handlers │ │ ├── LibraryEntryDataHandler.swift │ │ ├── LibraryEntryDeleteHandler.swift │ │ ├── LibraryEntryFinishedAtHandler.swift │ │ ├── LibraryEntryNotesHandler.swift │ │ ├── LibraryEntryPrivateHandler.swift │ │ ├── LibraryEntryProgressHandler.swift │ │ ├── LibraryEntryRatingHandler.swift │ │ ├── LibraryEntryReconsumeCountHandler.swift │ │ ├── LibraryEntryReconsumingHandler.swift │ │ ├── LibraryEntryStartedAtHandler.swift │ │ └── LibraryEntryStatusHandler.swift │ ├── Header │ │ ├── EntryMediaHeaderView.swift │ │ ├── EntryMediaHeaderView.xib │ │ └── EntryMediaHeaderViewData.swift │ ├── LibraryEntryViewController.swift │ ├── LibraryEntryViewData.swift │ ├── TextEditingViewController.swift │ └── TextEditingViewController.xib ├── Library │ ├── Data Sources │ │ ├── FullLibraryDataSource.swift │ │ ├── LocalSearchDataSource.swift │ │ └── UserLibraryViewDataSource.swift │ ├── LibraryEntry+LibraryView.swift │ ├── LibraryFilterViewController.swift │ ├── LibraryViewController.swift │ └── UserLibraryViewController.swift ├── LibraryFetcher.swift ├── Login │ ├── LoginViewController.swift │ ├── LoginViewController.xib │ ├── SignupViewController.swift │ └── SignupViewController.xib ├── Media │ ├── AnimeViewController.swift │ ├── Cells │ │ ├── MediaInfoTableViewCell.swift │ │ ├── MediaInfoTableViewCell.xib │ │ ├── MediaTextTableViewCell.swift │ │ └── MediaTextTableViewCell.xib │ ├── MangaViewController.swift │ ├── MediaFranchiseController.swift │ ├── MediaTableHeaderView.swift │ ├── MediaTableHeaderView.xib │ ├── MediaViewController.swift │ └── MediaViewControllerHelper.swift ├── Ookami.entitlements ├── Shared │ ├── Cells │ │ ├── CollectionViewTableViewCell.swift │ │ └── CollectionViewTableViewCell.xib │ ├── CollectionCellSpacer │ │ ├── CollectionCellSpacer.swift │ │ ├── CollectionCellSpacerOption.swift │ │ └── CollectionCellSpacing.swift │ ├── Controllers │ │ ├── NavigationHidingViewController.swift │ │ └── SearchItemViewController.swift │ ├── ErrorAlert.swift │ ├── Filtering │ │ ├── Filter.swift │ │ ├── FilterValueViewController.swift │ │ └── FilterViewController.swift │ ├── FullScreenActivityIndicator.swift │ ├── Item View │ │ ├── Cells │ │ │ ├── ItemDetailGridCell.swift │ │ │ ├── ItemDetailGridCell.xib │ │ │ ├── ItemSimpleGridCell.swift │ │ │ └── ItemSimpleGridCell.xib │ │ ├── Data Source │ │ │ └── PaginatedItemViewDataSourceBase.swift │ │ ├── ItemData+OokamiKit.swift │ │ ├── ItemData.swift │ │ ├── ItemUpdatable.swift │ │ ├── ItemViewController.swift │ │ └── ItemViewSizeHandler.swift │ ├── MailComposer.swift │ ├── Theme.swift │ └── Views │ │ ├── GradientView.swift │ │ ├── NibLoadableView.swift │ │ ├── TitleTableSectionView.swift │ │ └── TitleTableSectionView.xib ├── Supporting Files │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Images │ │ ├── book.png │ │ ├── default-cover.png │ │ ├── default-poster.jpg │ │ ├── kitsu.png │ │ ├── ookami-icon.png │ │ └── search.png │ └── Info.plist ├── Trending │ ├── MediaTrendingTableViewController.swift │ ├── Seasonal View │ │ ├── Data sources │ │ │ └── AnimeSeasonalDataSource.swift │ │ └── SeasonalTrendingViewController.swift │ ├── Shared │ │ ├── MediaBaseTrendingTableDataSource.swift │ │ └── PaginatedTrendingViewController.swift │ ├── Table View │ │ ├── AnimeTrendingTableViewController.swift │ │ ├── Data sources │ │ │ ├── AnimeTrendingTableDataSource.swift │ │ │ ├── AnimeWeeklyTrendingTableDataSource.swift │ │ │ ├── MangaTrendingTableDataSource.swift │ │ │ ├── MangaWeeklyTrendingTableDataSource.swift │ │ │ ├── MediaTrendingTableDataSource.swift │ │ │ └── TrendingTableDataSource.swift │ │ ├── MangaTrendingTableViewController.swift │ │ └── TrendingTableViewController.swift │ └── Year View │ │ ├── Data sources │ │ ├── AnimeYearTrendingDataSource.swift │ │ ├── MangaYearTrendingDataSource.swift │ │ └── MediaYearTrendingDataSource.swift │ │ └── YearTrendingViewController.swift ├── Vendor │ └── Iconic │ │ ├── LICENSE │ │ ├── README.md │ │ └── Source │ │ ├── Catalog │ │ ├── FontAwesome.otf │ │ ├── data.json │ │ ├── index.html │ │ ├── script.js │ │ └── style.css │ │ ├── FontAwesomeIcon.swift │ │ └── IconDrawable.swift └── main.swift ├── OokamiKit ├── Classes │ ├── Constants.swift │ ├── Kitsu │ │ ├── API │ │ │ ├── AnimeService.swift │ │ │ ├── AuthenticationService.swift │ │ │ ├── BaseService.swift │ │ │ ├── DiscoverService.swift │ │ │ ├── LibraryService.swift │ │ │ ├── MangaService.swift │ │ │ ├── MediaService.swift │ │ │ └── UserService.swift │ │ ├── CurrentUser.swift │ │ ├── Filters │ │ │ ├── AnimeFilter.swift │ │ │ ├── MangaFilter.swift │ │ │ ├── MediaFilter.swift │ │ │ └── RangeFilter.swift │ │ ├── Pagination │ │ │ ├── PaginatedLibrary.swift │ │ │ └── PaginatedService.swift │ │ ├── Preloader.swift │ │ └── Requests │ │ │ ├── KitsuLibraryRequest.swift │ │ │ ├── KitsuPagedRequest.swift │ │ │ └── KitsuRequest.swift │ ├── MigrationManager.swift │ ├── Networking │ │ ├── NetworkClient.swift │ │ └── NetworkRequest.swift │ ├── Ookami.swift │ ├── Operations │ │ ├── AsynchronousOperation.swift │ │ ├── FetchAllLibraryOperation.swift │ │ ├── FetchLibraryOperation.swift │ │ └── NetworkOperation.swift │ ├── Protocols │ │ ├── Cacheable.swift │ │ ├── GettableObject.swift │ │ ├── JSONParsable.swift │ │ ├── NetworkClientProtocol.swift │ │ ├── NetworkRequestProtocol.swift │ │ └── RealmStorable.swift │ └── Utility │ │ ├── CacheManager.swift │ │ ├── Database.swift │ │ ├── LibraryEntryUpdater.swift │ │ ├── OokamiTokenStore.swift │ │ ├── Parser.swift │ │ ├── RealmProvider.swift │ │ └── UserHelper.swift ├── Extensions │ └── Date+Utils.swift ├── Models │ ├── Anime.swift │ ├── Genre.swift │ ├── LastFetched.swift │ ├── LibraryEntry.swift │ ├── Manga.swift │ ├── Media+Genre.swift │ ├── MediaRelationship.swift │ ├── MediaTitle.swift │ ├── README │ └── User.swift ├── OokamiKit.h └── Supporting Files │ └── Info.plist ├── OokamiKitTests ├── Data │ ├── anime-hunter-hunter.json │ ├── entry-anime-jigglyslime.json │ ├── genre-adventure.json │ ├── manga-one-punch-man.json │ └── user-jigglyslime.json ├── Info.plist └── Specs │ ├── Classes │ ├── AnimeFilterSpec.swift │ ├── AuthentionServiceSpec.swift │ ├── CacheManagerSpec.swift │ ├── CurrentUserSpec.swift │ ├── DatabaseSpec.swift │ ├── KitsuLibraryRequestSpec.swift │ ├── KitsuPagedRequestSpec.swift │ ├── KitsuRequestSpec.swift │ ├── LibraryEntryUpdaterSpec.swift │ ├── LibraryServiceSpec.swift │ ├── MangaFilterSpec.swift │ ├── MediaFilterSpec.swift │ ├── PaginatedServiceSpec.swift │ ├── ParserSpec.swift │ ├── RangeFilterSpec.swift │ ├── UserHelperSpec.swift │ └── UserServiceSpec.swift │ ├── Helper │ ├── Helper.swift │ ├── StubAuthHeimdallr.swift │ ├── StubCacheObject.swift │ └── StubRealmObject.swift │ ├── Models │ ├── AnimeSpec.swift │ ├── GenreSpec.swift │ ├── LibraryEntrySpec.swift │ ├── MangaSpec.swift │ ├── RealmGettableObjectSpec.swift │ └── UserSpec.swift │ ├── Networking │ └── NetworkClientSpec.swift │ └── Operations │ ├── FetchAllLibraryOperationSpec.swift │ ├── FetchLibraryOperationSpec.swift │ └── NetworkOperationSpec.swift ├── OokamiTests ├── Default.swift └── Info.plist ├── OokamiUITests ├── Info.plist ├── OokamiUITests.swift └── SnapshotHelper.swift ├── Podfile ├── Podfile.lock ├── README.md └── fastlane ├── README.md ├── Snapfile ├── SnapshotHelper.swift ├── SnapshotHelper2-3.swift ├── Template-Deliverfile ├── Template-Fastfile └── metadata ├── copyright.txt ├── en-US ├── description.txt ├── keywords.txt ├── marketing_url.txt ├── name.txt ├── privacy_url.txt ├── release_notes.txt └── support_url.txt ├── iTunesArtwork@2x.png ├── itunes_rating_config.json ├── primary_category.txt ├── primary_first_sub_category.txt ├── primary_second_sub_category.txt ├── secondary_category.txt ├── secondary_first_sub_category.txt └── secondary_second_sub_category.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Custom 6 | *.psd 7 | *.psb 8 | 9 | ## Build generated 10 | build/ 11 | DerivedData/ 12 | 13 | ## Various settings 14 | *.pbxuser 15 | !default.pbxuser 16 | *.mode1v3 17 | !default.mode1v3 18 | *.mode2v3 19 | !default.mode2v3 20 | *.perspectivev3 21 | !default.perspectivev3 22 | xcuserdata/ 23 | 24 | ## Other 25 | *.moved-aside 26 | *.xcuserstate 27 | *.cer 28 | *.mobileprovision 29 | 30 | 31 | ## Obj-C/Swift specific 32 | *.hmap 33 | *.ipa 34 | *.dSYM.zip 35 | *.dSYM 36 | 37 | ## Playgrounds 38 | timeline.xctimeline 39 | playground.xcworkspace 40 | 41 | # Swift Package Manager 42 | # 43 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 44 | # Packages/ 45 | .build/ 46 | 47 | # CocoaPods 48 | # 49 | # We recommend against adding the Pods directory to your .gitignore. However 50 | # you should judge for yourself, the pros and cons are mentioned at: 51 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 52 | # 53 | Pods/ 54 | 55 | # Carthage 56 | # 57 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 58 | # Carthage/Checkouts 59 | 60 | Carthage/Build 61 | 62 | # fastlane 63 | # 64 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 65 | # screenshots whenever they are needed. 66 | # For more information about the recommended setup visit: 67 | # https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md 68 | 69 | fastlane/report.xml 70 | fastlane/Preview.html 71 | Preview.html 72 | fastlane/screenshots 73 | fastlane/test_output 74 | 75 | #Ignore the Deliverfile as that contains some private info 76 | #Use the TemplateDeliverfile 77 | Deliverfile 78 | Appfile 79 | 80 | #We ignore the Fastfile so that `fastlane init` works properley 81 | Fastfile 82 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2017 Mikunj Varsani 4 | 5 | Author Note: Please do not publish the app as-is on the Appstore, make some modifications that will allow people to be able to distinguish the apps. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /Ookami.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Ookami.xcodeproj/xcuserdata/Maka.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Ookami.xcscheme 8 | 9 | orderHint 10 | 0 11 | 12 | OokamiKit.xcscheme 13 | 14 | orderHint 15 | 2 16 | 17 | Testing without UI.xcscheme 18 | 19 | orderHint 20 | 1 21 | 22 | 23 | SuppressBuildableAutocreation 24 | 25 | 245179431E345CD20025F9C7 26 | 27 | primary 28 | 29 | 30 | 2464F1CD1DCC58800028F7B0 31 | 32 | primary 33 | 34 | 35 | 2464F1E11DCC58800028F7B0 36 | 37 | primary 38 | 39 | 40 | 2464F1F51DCC5AE60028F7B0 41 | 42 | primary 43 | 44 | 45 | 2464F1FD1DCC5AE70028F7B0 46 | 47 | primary 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Ookami.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/1password.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "onepassword-button.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "onepassword-button@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "onepassword-button@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/1password.imageset/onepassword-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/1password.imageset/onepassword-button.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/1password.imageset/onepassword-button@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/1password.imageset/onepassword-button@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/1password.imageset/onepassword-button@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/1password.imageset/onepassword-button@3x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-72.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-72@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-50.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small-50@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/Icon@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/AppIcon.appiconset/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Book_tab_bar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "book.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "compression-type" : "lossless", 14 | "template-rendering-intent" : "template" 15 | } 16 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Book_tab_bar.imageset/book.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/Book_tab_bar.imageset/book.pdf -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Check.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "check.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Check.imageset/check.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/Check.imageset/check.pdf -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Close.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "close.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Close.imageset/close.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/Close.imageset/close.pdf -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Search_tab_bar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Search-ios.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "compression-type" : "lossless", 14 | "template-rendering-intent" : "template" 15 | } 16 | } -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Search_tab_bar.imageset/Search-ios.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/Search_tab_bar.imageset/Search-ios.pdf -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Trending_tab_bar.imageset/Bar.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Assets.xcassets/Trending_tab_bar.imageset/Bar.pdf -------------------------------------------------------------------------------- /Ookami/Assets.xcassets/Trending_tab_bar.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Bar.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /Ookami/Discover/Data Sources/AnimeDiscoverDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeDiscoverDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 8/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | final class AnimeDiscoverDataSource: MediaDiscoverDataSource { 13 | 14 | //The filter to apply to the results 15 | var filter: AnimeFilter { 16 | didSet { 17 | //Fetch the anime again with the new filters 18 | update(search: currentSearch) 19 | } 20 | } 21 | 22 | /// Create an Anime Discover Data Source 23 | /// 24 | /// - Parameter filter: The initial filter to use 25 | init(filter: AnimeFilter = AnimeFilter()) { 26 | self.filter = filter 27 | super.init() 28 | } 29 | 30 | /// Get the paginated service for the current search string 31 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService { 32 | return DiscoverService().find(type: .anime, title: currentSearch, filters: filter) { [weak self] ids, error, original in 33 | 34 | completion() 35 | 36 | guard error == nil, 37 | let ids = ids else { 38 | if error as? PaginationError != nil { 39 | //Don't print anything if it's pagination related 40 | return 41 | } 42 | 43 | print(error!.localizedDescription) 44 | return 45 | } 46 | 47 | //We should return the results in order they were recieved so that users can get the best results 48 | let anime = ids.flatMap { Anime.get(withId: $0) } 49 | self?.updateItemData(from: anime, original: original) 50 | 51 | //If the device is an ipad and it's the original then we fetch the next page so that content is filled up on the screen 52 | if UIDevice.current.userInterfaceIdiom == .pad && original { 53 | self?.loadMore() 54 | } 55 | } 56 | } 57 | 58 | //MARK:- ItemDataSource 59 | override func didSelectItem(at indexPath: IndexPath) { 60 | if let parent = parent, 61 | let anime = self.data[indexPath.row] as? Anime { 62 | AppCoordinator.showAnimeVC(in: parent, anime: anime) 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Ookami/Discover/Data Sources/MangaDiscoverDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaDiscoverDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 8/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | final class MangaDiscoverDataSource: MediaDiscoverDataSource { 13 | 14 | //The filter to apply to the results 15 | var filter: MangaFilter { 16 | didSet { 17 | //Fetch the manga again with the new filters 18 | update(search: currentSearch) 19 | } 20 | } 21 | 22 | /// Create a Manga Discover Data Source 23 | /// 24 | /// - Parameter filter: The initial filter to use 25 | init(filter: MangaFilter = MangaFilter()) { 26 | self.filter = filter 27 | super.init() 28 | } 29 | 30 | /// Get the paginated service for the current search string 31 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService { 32 | return DiscoverService().find(type: .manga, title: currentSearch, filters: filter) { [weak self] ids, error, original in 33 | 34 | completion() 35 | 36 | guard error == nil, 37 | let ids = ids else { 38 | if error as? PaginationError != nil { 39 | //Don't print anything if it's pagination related 40 | return 41 | } 42 | 43 | print(error!.localizedDescription) 44 | return 45 | } 46 | 47 | let manga = ids.flatMap { Manga.get(withId: $0) } 48 | self?.updateItemData(from: manga, original: original) 49 | 50 | //If the device is an ipad and it's the original then we fetch the next page so that content is filled up on the screen 51 | if UIDevice.current.userInterfaceIdiom == .pad && original { 52 | self?.loadMore() 53 | } 54 | } 55 | } 56 | 57 | //MARK:- ItemDataSource 58 | override func didSelectItem(at indexPath: IndexPath) { 59 | if let parent = parent, 60 | let manga = self.data[indexPath.row] as? Manga { 61 | AppCoordinator.showMangaVC(in: parent, manga: manga) 62 | } 63 | } 64 | 65 | } 66 | 67 | -------------------------------------------------------------------------------- /Ookami/Discover/Data Sources/MediaDiscoverDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaDiscoverDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 8/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | /// A data source for media discovery 13 | /// Must be subclassed 14 | class MediaDiscoverDataSource: PaginatedItemViewDataSourceBase, SearchDataSource { 15 | 16 | //The parent to report to 17 | weak var parent: UIViewController? 18 | 19 | //The current search text 20 | var currentSearch: String = "" 21 | 22 | var searchDisplayType: ItemViewController.CellType { 23 | return .simpleGrid 24 | } 25 | 26 | //The place holder text 27 | var searchBarPlaceHolder: String { 28 | return "Search by title, character or staff..." 29 | } 30 | 31 | //The paginated service 32 | //Also expose the method here so we know what needs to be overriden without going to the other classes. 33 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService? { 34 | fatalError("paginatedService(completion:) needs to be implemented in a subclass") 35 | } 36 | 37 | /// Update the discovery results. 38 | /// This will discard the previous service and fetch a whole new one. 39 | /// 40 | /// - Parameter search: The text to search for, or blank if you want everything 41 | func update(search: String = "") { 42 | 43 | //Set the current search 44 | currentSearch = search 45 | 46 | //Update the service 47 | updateService() 48 | } 49 | 50 | func didSearch(text: String) { 51 | if currentSearch != text { 52 | update(search: text) 53 | } 54 | } 55 | 56 | //MARK:- ItemDataSource 57 | 58 | override func dataSetImage() -> UIImage? { 59 | let size = CGSize(width: 44, height: 44) 60 | let color = UIColor.lightGray.lighter(amount: 0.1) 61 | return UIImage(named: "search")? 62 | .resize(size) 63 | .color(color) 64 | } 65 | 66 | override func dataSetTitle() -> NSAttributedString? { 67 | let title = "Could not find any results." 68 | let attributes = [NSFontAttributeName: UIFont.systemFont(ofSize: 16), 69 | NSForegroundColorAttributeName: UIColor.lightGray.lighter(amount: 0.1)] 70 | return NSAttributedString(string: title, attributes: attributes) 71 | } 72 | 73 | 74 | } 75 | -------------------------------------------------------------------------------- /Ookami/Discover/Filters/BaseMediaFilterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseMediaFilterViewController.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 24/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import Cartography 12 | 13 | class BaseMediaFilterViewController: UIViewController { 14 | 15 | //The filter view 16 | var filterView: FilterViewController 17 | 18 | init() { 19 | filterView = FilterViewController(filters: []) 20 | super.init(nibName: nil, bundle: nil) 21 | self.reload() 22 | } 23 | 24 | required init?(coder aDecoder: NSCoder) { 25 | fatalError("use init() instead.") 26 | } 27 | 28 | override func viewDidLoad() { 29 | super.viewDidLoad() 30 | 31 | self.view.backgroundColor = Theme.ControllerTheme().backgroundColor 32 | 33 | //Add the filter view 34 | self.addChildViewController(filterView) 35 | self.view.addSubview(filterView.view) 36 | 37 | constrain(filterView.view) { view in 38 | view.edges == view.superview!.edges 39 | } 40 | 41 | filterView.didMove(toParentViewController: self) 42 | 43 | //Add the save and cancel buttons 44 | let cancelImage = UIImage(named: "Close") 45 | let cancel = UIBarButtonItem(image: cancelImage, style: .done, target: self, action: #selector(didCancel)) 46 | 47 | let clear = UIBarButtonItem(withIcon: .trashIcon, size: CGSize(width: 22, height: 22), target: self, action: #selector(didClear)) 48 | 49 | let saveImage = UIImage(named: "Check") 50 | let save = UIBarButtonItem(image: saveImage, style: .done, target: self, action: #selector(didSave)) 51 | 52 | self.navigationItem.leftBarButtonItem = cancel 53 | self.navigationItem.rightBarButtonItems = [save, clear] 54 | } 55 | 56 | func didCancel() { 57 | dismiss(animated: true) 58 | } 59 | 60 | func didSave() { 61 | dismiss(animated: true) 62 | } 63 | 64 | func didClear() { 65 | 66 | } 67 | 68 | func reload() { 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Ookami/Discover/Filters/MangaFilterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaFilterViewController.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 24/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class MangaFilterViewController: BaseMediaFilterViewController { 13 | 14 | //The block that gets called upon saving 15 | fileprivate var onSave: (MangaFilter) -> Void 16 | 17 | //The current filter we are editing 18 | fileprivate var filter: MangaFilter 19 | 20 | /// Create a manga filter view controller 21 | /// 22 | /// - Parameters: 23 | /// - filter: The initial manga filter 24 | /// - onSave: The block which gets called when save is pressed. Passes back the new manga filter. 25 | init(filter: MangaFilter, onSave: @escaping (MangaFilter) -> Void) { 26 | self.onSave = onSave 27 | self.filter = filter.copy() 28 | super.init() 29 | } 30 | 31 | required init?(coder aDecoder: NSCoder) { 32 | fatalError("use init(filter:onSave:) instead.") 33 | } 34 | 35 | override func didSave() { 36 | dismiss(animated: true) { 37 | self.onSave(self.filter) 38 | } 39 | } 40 | 41 | override func didClear() { 42 | self.filter = MangaFilter() 43 | reload() 44 | } 45 | 46 | override func reload() { 47 | filterView.filters = filters() 48 | } 49 | } 50 | 51 | extension MangaFilterViewController { 52 | 53 | func filters() -> [FilterGroup] { 54 | let helper = DiscoverFilterHelper() 55 | 56 | let sort = helper.sortFilter(from: filter) { self.reload() } 57 | let year = helper.yearFilter(from: filter) { self.reload() } 58 | let score = helper.scoreFilter(from: filter) { self.reload() } 59 | 60 | //Other 61 | let type = typeFilter() 62 | let genre = helper.genreFilter(from: filter) 63 | 64 | let other = FilterGroup(name: "", filters: [type, genre]) 65 | 66 | 67 | return [sort, year, score, other] 68 | } 69 | 70 | //The type filter 71 | func typeFilter() -> Filter { 72 | return MultiValueFilter(name: "Type", 73 | values: Manga.SubType.all.map { $0.rawValue }, 74 | selectedValues: filter.subtypes.map { $0.rawValue }, 75 | onChange: { selected in 76 | self.filter.subtypes = selected.flatMap { Manga.SubType(rawValue: $0) } 77 | }) 78 | } 79 | } 80 | 81 | -------------------------------------------------------------------------------- /Ookami/Extenstions/Double+Ookami.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double+Ookami.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 11/4/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Double { 12 | func round(nearest: Double) -> Double { 13 | let n = 1/nearest 14 | let numberToRound = self * n 15 | return numberToRound.rounded(.down) / n 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Ookami/Extenstions/UIImage+Ookami.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Ookami.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 22/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIImage { 12 | 13 | /// Set the tint color of the image 14 | /// 15 | /// - Parameter color: The color 16 | /// - Returns: The tinted image or nil if failed. 17 | func color(_ color: UIColor) -> UIImage? { 18 | let maskImage = cgImage! 19 | 20 | let width = size.width 21 | let height = size.height 22 | let bounds = CGRect(x: 0, y: 0, width: width, height: height) 23 | 24 | let colorSpace = CGColorSpaceCreateDeviceRGB() 25 | let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) 26 | let context = CGContext(data: nil, width: Int(width), height: Int(height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo.rawValue)! 27 | 28 | context.clip(to: bounds, mask: maskImage) 29 | context.setFillColor(color.cgColor) 30 | context.fill(bounds) 31 | 32 | if let cgImage = context.makeImage() { 33 | let coloredImage = UIImage(cgImage: cgImage) 34 | return coloredImage 35 | } else { 36 | return nil 37 | } 38 | } 39 | 40 | /// Resize a given UIImage 41 | /// 42 | /// - Parameter size: The new size 43 | /// - Returns: The resized image 44 | func resize(_ size: CGSize) -> UIImage { 45 | 46 | guard self.size != size else { return self } 47 | 48 | UIGraphicsBeginImageContextWithOptions(size, false, 0.0) 49 | self.draw(in: CGRect(origin: CGPoint.zero, size: size)) 50 | 51 | let scaledImage = UIGraphicsGetImageFromCurrentImageContext() 52 | UIGraphicsEndImageContext() 53 | return scaledImage! 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Cells/EntryBoolTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryBoolTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class EntryBoolTableViewCell: UITableViewCell, NibReusable { 13 | 14 | @IBOutlet weak var headingLabel: UILabel! 15 | 16 | override func awakeFromNib() { 17 | super.awakeFromNib() 18 | headingLabel.textColor = Theme.EntryView().headingColor 19 | } 20 | 21 | override func setSelected(_ selected: Bool, animated: Bool) { 22 | super.setSelected(selected, animated: animated) 23 | 24 | // Configure the view for the selected state 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Cells/EntryButtonTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryButtonTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | protocol EntryButtonDelegate: class { 13 | func didTapButton(inCell: EntryButtonTableViewCell, indexPath: IndexPath?) 14 | } 15 | 16 | class EntryButtonTableViewCell: UITableViewCell, NibReusable { 17 | 18 | @IBOutlet weak var headingLabel: UILabel! 19 | 20 | @IBOutlet weak var valueLabel: UILabel! 21 | 22 | @IBOutlet weak var button: UIButton! 23 | 24 | var indexPath: IndexPath? 25 | weak var delegate: EntryButtonDelegate? 26 | 27 | override func awakeFromNib() { 28 | super.awakeFromNib() 29 | 30 | headingLabel.textColor = Theme.EntryView().headingColor 31 | valueLabel.textColor = Theme.EntryView().valueColor 32 | button.setTitleColor(Theme.EntryView().tintColor, for: .normal) 33 | } 34 | 35 | 36 | @IBAction func didTapButton(_ sender: UIButton) { 37 | delegate?.didTapButton(inCell: self, indexPath: indexPath) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Cells/EntryStringTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryStringTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class EntryStringTableViewCell: UITableViewCell, NibReusable { 13 | 14 | @IBOutlet weak var headingLabel: UILabel! 15 | 16 | @IBOutlet weak var valueLabel: UILabel! 17 | 18 | override func awakeFromNib() { 19 | super.awakeFromNib() 20 | 21 | headingLabel.textColor = Theme.EntryView().headingColor 22 | valueLabel.textColor = Theme.EntryView().valueColor 23 | } 24 | 25 | override func setSelected(_ selected: Bool, animated: Bool) { 26 | super.setSelected(selected, animated: animated) 27 | 28 | // Configure the view for the selected state 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryDataHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryDataHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | protocol LibraryEntryDataHandler { 13 | 14 | //The heading that it handles 15 | var heading: LibraryEntryViewData.Heading { get } 16 | 17 | //The table data 18 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData 19 | 20 | //The handling of tap 21 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) 22 | } 23 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryDeleteHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryDeleteHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class LibraryEntryDeleteHandler: LibraryEntryDataHandler { 13 | 14 | var heading: LibraryEntryViewData.Heading { 15 | return .delete 16 | } 17 | 18 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 19 | return LibraryEntryViewData.TableData(type: .delete, value: "Delete library entry", heading: heading) 20 | } 21 | 22 | //The handling of tap 23 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 24 | let sheet = UIAlertController(title: nil, message: "Are you sure?", preferredStyle: .actionSheet) 25 | 26 | sheet.popoverPresentationController?.sourceView = cell 27 | sheet.popoverPresentationController?.sourceRect = cell.bounds 28 | 29 | sheet.addAction(UIAlertAction(title: "Yes, Delete it!", style: .destructive) { action in 30 | 31 | //Send the delete action and show error if it occurred 32 | controller.showIndicator() 33 | LibraryService().delete(entry: updater.entry) { error in 34 | controller.hideIndicator() 35 | guard error == nil else { 36 | ErrorAlert.showAlert(in: controller, title: "Failed to delete entry", message: error!.localizedDescription) 37 | return 38 | } 39 | 40 | let _ = controller.navigationController?.popViewController(animated: true) 41 | } 42 | }) 43 | 44 | sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) 45 | 46 | //Present the sheet if we haven't 47 | if controller.presentedViewController == nil { 48 | controller.present(sheet, animated: true) 49 | } 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryFinishedAtHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryFinishedAtHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 26/6/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryFinishedAtHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .finishedAt 17 | } 18 | 19 | //The table data 20 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 21 | 22 | //Format the date to dd/MM/YYYY 23 | let formatter = DateFormatter() 24 | formatter.dateFormat = "dd/MM/YYYY" 25 | formatter.timeZone = TimeZone(abbreviation: "UTC") 26 | 27 | var date = "-" 28 | if let finish = entry.finishedAt { 29 | date = formatter.string(from: finish) 30 | } 31 | 32 | 33 | return LibraryEntryViewData.TableData(type: .string, value: date, heading: heading) 34 | } 35 | 36 | //The handling of tap 37 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 38 | 39 | let selected = updater.entry.finishedAt ?? Date() 40 | 41 | let picker = ActionSheetDatePicker(title: "Finished At:", datePickerMode: .date, selectedDate: selected, doneBlock: { _, value, _ in 42 | updater.update(finishedAt: value as? Date) 43 | controller.reloadData() 44 | }, cancel: { _ in }, origin: cell) 45 | 46 | picker?.minimumDate = Date(timeIntervalSince1970: 0) 47 | if let start = updater.entry.startedAt { 48 | picker?.minimumDate = start 49 | } 50 | 51 | picker?.maximumDate = Date() 52 | picker?.timeZone = TimeZone(abbreviation: "UTC") 53 | picker?.show() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryNotesHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryNotesHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class LibraryEntryNotesHandler: LibraryEntryDataHandler { 13 | 14 | var heading: LibraryEntryViewData.Heading { 15 | return .notes 16 | } 17 | 18 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 19 | return LibraryEntryViewData.TableData(type: .string, value: entry.notes, heading: heading) 20 | } 21 | 22 | //The handling of tap 23 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 24 | 25 | let editingVC = TextEditingViewController(title: "Notes", text: updater.entry.notes, placeholder: "Type your notes here!") 26 | editingVC.modalPresentationStyle = .overCurrentContext 27 | editingVC.delegate = controller 28 | 29 | let vc = controller.tabBarController ?? controller 30 | vc.present(editingVC, animated: false) 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryPrivateHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryPrivateHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryPrivateHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .isPrivate 17 | } 18 | 19 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 20 | return LibraryEntryViewData.TableData(type: .bool, value: entry.isPrivate, heading: heading) 21 | } 22 | 23 | //The handling of tap 24 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 25 | updater.update(isPrivate: !updater.entry.isPrivate) 26 | controller.reloadData() 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryProgressHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryProgressHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryProgressHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .progress 17 | } 18 | 19 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 20 | 21 | let max = entry.maxProgress() 22 | let progressValue = max != nil ? "\(entry.progress) of \(max!)" : "\(entry.progress)" 23 | 24 | return LibraryEntryViewData.TableData(type: .button, value: progressValue, heading: heading) 25 | } 26 | 27 | //The handling of tap 28 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 29 | 30 | let max = updater.entry.maxProgress() ?? 999 31 | let rows = Array(0...max) 32 | ActionSheetStringPicker.show(withTitle: "Progress", rows: rows, initialSelection: updater.entry.progress, doneBlock: { picker, index, value in 33 | if let newValue = value as? Int { 34 | updater.update(progress: newValue) 35 | controller.reloadData() 36 | } 37 | }, cancel: { _ in }, origin: cell) 38 | 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryReconsumeCountHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryReconsumeCountHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryReconsumeCountHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .reconsumeCount 17 | } 18 | 19 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 20 | let reconsumedString = "\(entry.reconsumeCount) times" 21 | return LibraryEntryViewData.TableData(type: .button, value: reconsumedString, heading: heading) 22 | } 23 | 24 | //The handling of tap 25 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 26 | let rows = Array(0...999) 27 | ActionSheetStringPicker.show(withTitle: "Reconsume Count", rows: rows, initialSelection: updater.entry.reconsumeCount, doneBlock: { picker, index, value in 28 | if let newValue = value as? Int { 29 | updater.update(reconsumeCount: newValue) 30 | controller.reloadData() 31 | } 32 | }, cancel: { _ in }, origin: cell) 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryReconsumingHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryReconsumingHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class LibraryEntryReconsumingHandler: LibraryEntryDataHandler { 13 | 14 | var heading: LibraryEntryViewData.Heading { 15 | return .reconsuming 16 | } 17 | 18 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 19 | return LibraryEntryViewData.TableData(type: .bool, value: entry.reconsuming, heading: heading) 20 | } 21 | 22 | //The handling of tap 23 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 24 | //Just invert the value 25 | updater.update(reconsuming: !updater.entry.reconsuming) 26 | controller.reloadData() 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryStartedAtHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryStartedAtHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 26/6/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryStartedAtHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .startedAt 17 | } 18 | 19 | //The table data 20 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 21 | 22 | //Format the date to dd/MM/YYYY 23 | let formatter = DateFormatter() 24 | formatter.dateFormat = "dd/MM/YYYY" 25 | formatter.timeZone = TimeZone(abbreviation: "UTC") 26 | 27 | var date = "-" 28 | if let start = entry.startedAt { 29 | date = formatter.string(from: start) 30 | } 31 | 32 | return LibraryEntryViewData.TableData(type: .string, value: date, heading: heading) 33 | } 34 | 35 | //The handling of tap 36 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 37 | 38 | let selected = updater.entry.startedAt ?? Date() 39 | 40 | let picker = ActionSheetDatePicker(title: "Started At:", datePickerMode: .date, selectedDate: selected, doneBlock: { _, value, _ in 41 | updater.update(startedAt: value as? Date) 42 | controller.reloadData() 43 | }, cancel: { _ in }, origin: cell) 44 | 45 | picker?.minimumDate = Date(timeIntervalSince1970: 0) 46 | picker?.maximumDate = Date() 47 | picker?.timeZone = TimeZone(abbreviation: "UTC") 48 | picker?.show() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Data Handlers/LibraryEntryStatusHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntryStatusHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | import ActionSheetPicker_3_0 12 | 13 | class LibraryEntryStatusHandler: LibraryEntryDataHandler { 14 | 15 | var heading: LibraryEntryViewData.Heading { 16 | return .status 17 | } 18 | 19 | func tableData(for entry: LibraryEntry) -> LibraryEntryViewData.TableData { 20 | var statusValue = "-" 21 | if let mediaStatus = entry.status, let type = entry.media?.type { 22 | statusValue = mediaStatus.toString(forMedia: type) 23 | } 24 | 25 | return LibraryEntryViewData.TableData(type: .string, value: statusValue, heading: heading) 26 | } 27 | 28 | //The handling of tap 29 | func didSelect(updater: LibraryEntryUpdater, cell: UITableViewCell, controller: LibraryEntryViewController) { 30 | 31 | let statuses = LibraryEntry.Status.all 32 | let rows: [String] = statuses.map { 33 | if let media = updater.entry.media { 34 | return $0.toString(forMedia: media.type) 35 | } 36 | 37 | return "-" 38 | } 39 | 40 | let initial = statuses.index(of: updater.entry.status ?? .current) ?? 0 41 | ActionSheetStringPicker.show(withTitle: "Status", rows: rows, initialSelection: initial, doneBlock: { picker, index, value in 42 | updater.update(status: statuses[index]) 43 | controller.reloadData() 44 | }, cancel: { _ in }, origin: cell) 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Ookami/Library Entry/Header/EntryMediaHeaderViewData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EntryMediaHeaderViewData.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 19/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | struct EntryMediaHeaderViewData { 13 | var name: String 14 | var details: String 15 | var synopsis: String 16 | var mediaType: Media.MediaType? = nil 17 | var posterImage: String? = nil 18 | var coverImage: String? = nil 19 | var shouldShowMediaButton: Bool = true 20 | 21 | init() { 22 | self.name = "" 23 | self.details = "" 24 | self.synopsis = "" 25 | } 26 | 27 | init(anime: Anime) { 28 | self.mediaType = .anime 29 | self.name = anime.canonicalTitle 30 | self.posterImage = anime.posterImage 31 | self.synopsis = anime.synopsis 32 | self.coverImage = anime.coverImage 33 | 34 | var details: [String] = [] 35 | details.append(anime.subtypeRaw.uppercased()) 36 | details.append(anime.episodeCountString) 37 | details.append(anime.episodeLengthString) 38 | self.details = details.joined(separator: " ᛫ ") 39 | } 40 | 41 | init(manga: Manga) { 42 | self.mediaType = .manga 43 | self.name = manga.canonicalTitle 44 | self.posterImage = manga.posterImage 45 | self.synopsis = manga.synopsis 46 | self.coverImage = manga.coverImage 47 | 48 | var details: [String] = [] 49 | details.append(manga.subtypeRaw.uppercased()) 50 | details.append(manga.chapterCountString) 51 | details.append(manga.volumeCountString) 52 | 53 | self.details = details.joined(separator: " ᛫ ") 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Ookami/Library/Data Sources/UserLibraryViewDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserLibraryViewDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 4/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | /// A struct which holds the datasources 13 | struct UserLibraryViewDataSource { 14 | 15 | enum Errors: Error { 16 | case invalidSources(description: String) 17 | } 18 | 19 | typealias StatusDataSource = [LibraryEntry.Status: LibraryDataSource] 20 | var anime: StatusDataSource 21 | var manga: StatusDataSource 22 | 23 | //The parent of the LibraryDataSources passed in 24 | weak var parent: LibraryDataSourceParent? { 25 | didSet { 26 | anime.values.forEach { $0.parent = parent } 27 | manga.values.forEach { $0.parent = parent } 28 | } 29 | } 30 | 31 | /// Create library data that holds all `LibraryViewDataSource` needed for `LibraryViewController` 32 | /// 33 | /// - Important: All statuses must have a data source. 34 | /// 35 | /// - Parameters: 36 | /// - anime: A dictionary of anime data sources 37 | /// - manga: A dictionary of manga data sources 38 | /// - Throws: `Errors.invalidSources(:)` if there were not enough data sources. 39 | init(anime: StatusDataSource, manga: StatusDataSource) throws { 40 | guard anime.count == LibraryEntry.Status.all.count else { 41 | throw Errors.invalidSources(description: "Anime - All statuses must have a datasource") 42 | } 43 | 44 | guard manga.count == LibraryEntry.Status.all.count else { 45 | throw Errors.invalidSources(description: "Manga - All statuses must have a datasource") 46 | } 47 | 48 | self.anime = anime 49 | self.manga = manga 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Ookami/Library/LibraryEntry+LibraryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryEntry+LibraryView.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 4/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | extension LibraryEntry.Status { 13 | // This has been commented incase the feature wants to be re-implemented in the future 14 | // func color() -> UIColor { 15 | // let theme = Theme.Status() 16 | // switch self { 17 | // case .current: 18 | // return theme.current 19 | // case .planned: 20 | // return theme.planned 21 | // case .completed: 22 | // return theme.completed 23 | // case .onHold: 24 | // return theme.onHold 25 | // case .dropped: 26 | // return theme.dropped 27 | // 28 | // } 29 | // } 30 | 31 | /* NOTE: Due to the similarity of Ookami and Aozora, the colors under the statuses will be defaulted to white. When making this i thought the way it was in Aozora was brilliant, but alas it made the app look way to similar too it and thus there was some backlash.*/ 32 | //Get the UIColor for a given status 33 | func color() -> UIColor { 34 | return Theme.Colors().secondary 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Ookami/LibraryFetcher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LibraryFetcher.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 12/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import OokamiKit 11 | 12 | //A class which is used to fetch the current users library every 20 minutes 13 | //TODO: Maybe implement Background Fetching? 14 | class LibraryFetcher { 15 | 16 | var timer: Timer? 17 | var interval: TimeInterval = 60 * 3 //Every 3 minutes 18 | var timesCalled: Int = 0 19 | 20 | @objc func updateLibrary() { 21 | //We need the current user to be logged in 22 | guard let user = CurrentUser().userID else { 23 | return 24 | } 25 | 26 | //Every 5 calls we should fetch the main library 27 | if timesCalled >= 5 { 28 | timesCalled = 0 29 | print("Updated full library") 30 | LibraryService().getAll(userID: user, type: .anime) { _ in } 31 | LibraryService().getAll(userID: user, type: .manga) { _ in } 32 | return 33 | } 34 | 35 | //Check if we have a last fetched object, if not then don't get the library 36 | //This avoids the issue of fetching a full users library every 3 minutes if we haven't initially fetched all of it 37 | if let fetched = LastFetched.get(withId: user) { 38 | print("Updated partial library") 39 | LibraryService().getAll(userID: user, type: .anime, since: fetched.anime) { _ in } 40 | LibraryService().getAll(userID: user, type: .manga, since: fetched.manga) { _ in } 41 | } 42 | 43 | timesCalled += 1 44 | } 45 | 46 | func startFetching() { 47 | if timer != nil { timer?.invalidate() } 48 | 49 | timer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(updateLibrary), userInfo: nil, repeats: true) 50 | 51 | //Call the update function at the start 52 | updateLibrary() 53 | } 54 | 55 | func stopFetching() { 56 | timer?.invalidate() 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /Ookami/Media/Cells/MediaInfoTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaInfoTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 20/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class MediaInfoTableViewCell: UITableViewCell, NibReusable { 13 | 14 | @IBOutlet weak var infoTitleLabel: UILabel! 15 | @IBOutlet weak var infoLabel: UILabel! 16 | 17 | override func awakeFromNib() { 18 | super.awakeFromNib() 19 | 20 | self.contentView.layoutMargins = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4) 21 | infoTitleLabel.textColor = Theme.Colors().secondary 22 | infoLabel.textColor = UIColor.black 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Ookami/Media/Cells/MediaTextTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaTextTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 13/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class MediaTextTableViewCell: UITableViewCell, NibReusable { 13 | 14 | @IBOutlet weak var simpleTextLabel: UILabel! 15 | 16 | override func awakeFromNib() { 17 | super.awakeFromNib() 18 | // Initialization code 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Ookami/Ookami.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | keychain-access-groups 6 | 7 | $(AppIdentifierPrefix)com.mikunjvarsani.Ookami 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Ookami/Shared/Cells/CollectionViewTableViewCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionViewTableViewCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 3/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | protocol CollectionViewTableViewCellDelegate: class { 13 | func didTapSeeAll(sender: CollectionViewTableViewCell) 14 | } 15 | 16 | class CollectionViewTableViewCell: UITableViewCell, NibReusable { 17 | 18 | @IBOutlet weak var upperHeightConstraint: NSLayoutConstraint! 19 | 20 | var collectionViewOffset: CGFloat { 21 | get { 22 | return collectionView.contentOffset.x 23 | } 24 | 25 | set { 26 | collectionView.contentOffset.x = newValue 27 | } 28 | } 29 | 30 | weak var delegate: CollectionViewTableViewCellDelegate? 31 | 32 | @IBOutlet weak var titleLabel: UILabel! 33 | 34 | @IBOutlet weak var detailLabel: UILabel! 35 | 36 | @IBOutlet weak var seeAllButton: UIButton! 37 | 38 | @IBOutlet weak var collectionView: UICollectionView! 39 | 40 | override func awakeFromNib() { 41 | super.awakeFromNib() 42 | } 43 | 44 | //Update the height of the upperview if the title, detail and button are not visible 45 | func updateHeightConstraint() { 46 | let height = 45.0 47 | let titleText = titleLabel.text?.isEmpty ?? true 48 | let detailText = detailLabel.text?.isEmpty ?? true 49 | 50 | upperHeightConstraint.constant = CGFloat((titleText && detailText && seeAllButton.isHidden) ? 0.0 : height) 51 | } 52 | 53 | func set(title: String) { 54 | titleLabel.text = title 55 | updateHeightConstraint() 56 | } 57 | 58 | func set(detail: String) { 59 | detailLabel.text = detail 60 | updateHeightConstraint() 61 | } 62 | 63 | func set(showSeeAll visible: Bool) { 64 | seeAllButton.isHidden = !visible 65 | updateHeightConstraint() 66 | } 67 | 68 | func setCollectionViewDataSourceDelegate(dataSourceDelegate: T, forRow row: Int) where 69 | T: UICollectionViewDelegate, 70 | T: UICollectionViewDataSource { 71 | 72 | self.tag = row 73 | collectionView.delegate = dataSourceDelegate 74 | collectionView.dataSource = dataSourceDelegate 75 | collectionView.tag = row 76 | collectionView.reloadData() 77 | } 78 | 79 | @IBAction func didTapSeeAll(_ sender: Any) { 80 | delegate?.didTapSeeAll(sender: self) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Ookami/Shared/CollectionCellSpacer/CollectionCellSpacerOption.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionCellSpacerOption.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 2/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct CollectionCellSpacerOption { 12 | 13 | enum Errors: Error { 14 | case invalidItemSize 15 | case invalidMinimumGutter 16 | case invalidAvailableWidth 17 | case invalidGutterToMarginRatio 18 | } 19 | 20 | var itemSize: CGSize 21 | var minimumGutter: CGFloat 22 | var gutterToMarginRatio: CGFloat 23 | var availableWidth: CGFloat 24 | var distributeExtraToMargins = true 25 | 26 | init(itemSize: CGSize, minimumGutter: CGFloat, availableWidth: CGFloat, gutterToMarginRatio: CGFloat = 1.0, distributeExtraToMargins: Bool = true) throws { 27 | 28 | guard itemSize.width > 0.0, itemSize.height > 0.0 else { 29 | throw Errors.invalidItemSize 30 | } 31 | 32 | guard minimumGutter > 0.0 else { 33 | throw Errors.invalidMinimumGutter 34 | } 35 | 36 | guard availableWidth > 0.0 else { 37 | throw Errors.invalidAvailableWidth 38 | } 39 | 40 | guard gutterToMarginRatio > 0.0 else { 41 | throw Errors.invalidGutterToMarginRatio 42 | } 43 | 44 | 45 | self.itemSize = itemSize; 46 | self.minimumGutter = minimumGutter; 47 | self.availableWidth = availableWidth; 48 | self.gutterToMarginRatio = gutterToMarginRatio 49 | self.distributeExtraToMargins = distributeExtraToMargins 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Ookami/Shared/CollectionCellSpacer/CollectionCellSpacing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CollectionCellSpacing.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 2/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct CollectionCellSpacing { 12 | //Number of items that fit, or 0 if spacing is invalid 13 | var itemCount = 0 14 | 15 | //Outer margin size (in points) 16 | var margin: CGFloat = 0.0 17 | 18 | //Inner gutter size (in points) 19 | var gutter: CGFloat = 0.0 20 | 21 | //Extra space that doesn't fit 22 | var extra: CGFloat = 0.0 23 | 24 | static let zero = CollectionCellSpacing() 25 | 26 | } 27 | 28 | extension CollectionCellSpacing : Equatable { 29 | static func == (lhs: CollectionCellSpacing, rhs: CollectionCellSpacing) -> Bool { 30 | return lhs.itemCount == rhs.itemCount && 31 | lhs.margin == rhs.margin && 32 | lhs.gutter == rhs.gutter && 33 | lhs.extra == rhs.extra; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Ookami/Shared/ErrorAlert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorAlert.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 12/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ErrorAlert { 12 | private init() {} 13 | 14 | static func showAlert(in controller: UIViewController, title: String, message: String) { 15 | DispatchQueue.main.async { 16 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 17 | let action = UIAlertAction(title: "Ok", style: .cancel, handler: nil) 18 | alert.addAction(action) 19 | 20 | //Present the alert if we haven't 21 | if controller.presentedViewController == nil { 22 | controller.present(alert, animated: true) 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Ookami/Shared/FullScreenActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FullScreenActivityIndicator.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 30/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Cartography 11 | import NVActivityIndicatorView 12 | 13 | //A class for displaying a full screen activity indicator as that on the login and signup pages 14 | class FullScreenActivityIndicator { 15 | 16 | //The activity indicator 17 | var activityIndicator: NVActivityIndicatorView = { 18 | let theme = Theme.ActivityIndicatorTheme() 19 | let view = NVActivityIndicatorView(frame: CGRect(origin: CGPoint.zero, size: theme.size), type: theme.type, color: theme.color) 20 | return view 21 | }() 22 | 23 | //The indicator overlay for displaying 24 | var indicatorOverlay: UIView = { 25 | let v = UIView() 26 | v.backgroundColor = UIColor.black.withAlphaComponent(0.5) 27 | return v 28 | }() 29 | 30 | func add(to view: UIView) { 31 | view.addSubview(indicatorOverlay) 32 | constrain(indicatorOverlay) { view in 33 | view.edges == view.superview!.edges 34 | } 35 | 36 | //Add the indicator ontop of the overlay 37 | indicatorOverlay.addSubview(activityIndicator) 38 | let size = Theme.ActivityIndicatorTheme().size 39 | constrain(activityIndicator) { view in 40 | view.center == view.superview!.center 41 | view.width == size.width 42 | view.height == size.height 43 | } 44 | 45 | hideIndicator() 46 | } 47 | 48 | func showIndicator() { 49 | UIView.animate(withDuration: 0.25) { 50 | self.activityIndicator.startAnimating() 51 | self.indicatorOverlay.isHidden = false 52 | } 53 | } 54 | 55 | func hideIndicator() { 56 | UIView.animate(withDuration: 0.25) { 57 | self.activityIndicator.stopAnimating() 58 | self.indicatorOverlay.isHidden = true 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Ookami/Shared/Item View/Cells/ItemDetailGridCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemDetailGridCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 1/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Kingfisher 11 | import Reusable 12 | import DynamicColor 13 | 14 | final class ItemDetailGridCell: UICollectionViewCell { 15 | 16 | @IBOutlet weak var posterImage: UIImageView! 17 | 18 | @IBOutlet weak var nameView: UIView! 19 | 20 | @IBOutlet weak var nameLabel: UILabel! 21 | 22 | @IBOutlet weak var moreDetailLabel: UILabel! 23 | 24 | @IBOutlet weak var contentDetailView: GradientView! 25 | 26 | @IBOutlet weak var contentDetailLabel: UILabel! 27 | 28 | override func awakeFromNib() { 29 | super.awakeFromNib() 30 | 31 | let labelTheme = Theme.TextTheme() 32 | moreDetailLabel.textColor = labelTheme.textColor 33 | 34 | let viewTheme = Theme.ViewTheme() 35 | let color = viewTheme.backgroundColor 36 | moreDetailLabel.backgroundColor = color 37 | posterImage.backgroundColor = color.isLight() ? color.darkened(amount: 0.1) : color.lighter(amount: 0.1) 38 | } 39 | } 40 | 41 | //MARK:- Reusable 42 | extension ItemDetailGridCell: NibReusable {} 43 | 44 | //MARK:- Item Updatable 45 | extension ItemDetailGridCell: ItemUpdatable { 46 | 47 | /// Update the cell with the given data 48 | /// 49 | /// - Parameter data: The item data to update with 50 | func update(data: ItemData, loadImages: Bool) { 51 | nameLabel.text = data.name ?? "-" 52 | moreDetailLabel.text = data.moreDetails ?? "-" 53 | 54 | //Check if we have details, else just hide the view 55 | let isEmpty = data.details?.isEmpty ?? true 56 | contentDetailView.isHidden = data.details == nil || isEmpty 57 | contentDetailLabel.text = data.details ?? "" 58 | 59 | //Set the image 60 | if loadImages, let poster = data.posterImage { 61 | let placeholder = UIImage(named: "default-poster.jpg") 62 | posterImage.kf.indicatorType = .activity 63 | posterImage.kf.setImage(with: URL(string: poster), placeholder: placeholder, options: [.transition(.fade(0.4)), .backgroundDecode]) 64 | } else { 65 | posterImage.image = nil 66 | } 67 | 68 | } 69 | 70 | func stopUpdating() { 71 | posterImage.kf.cancelDownloadTask() 72 | 73 | //This is to lower memory usage 74 | posterImage.image = nil 75 | } 76 | } 77 | 78 | 79 | -------------------------------------------------------------------------------- /Ookami/Shared/Item View/Cells/ItemSimpleGridCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemSimpleGridCell.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 28/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class ItemSimpleGridCell: UICollectionViewCell { 13 | 14 | @IBOutlet weak var posterImage: UIImageView! 15 | 16 | @IBOutlet weak var nameView: UIView! 17 | 18 | @IBOutlet weak var nameLabel: UILabel! 19 | 20 | @IBOutlet weak var contentDetailView: GradientView! 21 | 22 | @IBOutlet weak var contentDetailLabel: UILabel! 23 | 24 | override func awakeFromNib() { 25 | super.awakeFromNib() 26 | 27 | let viewTheme = Theme.ViewTheme() 28 | let color = viewTheme.backgroundColor 29 | posterImage.backgroundColor = color.isLight() ? color.darkened(amount: 0.1) : color.lighter(amount: 0.1) 30 | } 31 | 32 | } 33 | 34 | //MARK:- Reusable 35 | extension ItemSimpleGridCell: NibReusable {} 36 | 37 | //MARK:- Item Updatable 38 | extension ItemSimpleGridCell: ItemUpdatable { 39 | 40 | /// Update the cell with the given data 41 | /// 42 | /// - Parameter data: The item data to update with 43 | func update(data: ItemData, loadImages: Bool) { 44 | nameLabel.text = data.name ?? "-" 45 | 46 | //Check if we have details, else just hide the view 47 | let isEmpty = data.details?.isEmpty ?? true 48 | contentDetailView.isHidden = data.details == nil || isEmpty 49 | contentDetailLabel.text = data.details ?? "" 50 | 51 | //Set the image 52 | if loadImages, let poster = data.posterImage { 53 | let placeholder = UIImage(named: "default-poster.jpg") 54 | posterImage.kf.indicatorType = .activity 55 | posterImage.kf.setImage(with: URL(string: poster), placeholder: placeholder, options: [.transition(.fade(0.4)), .backgroundDecode]) 56 | } else { 57 | posterImage.image = nil 58 | } 59 | 60 | } 61 | 62 | func stopUpdating() { 63 | posterImage.kf.cancelDownloadTask() 64 | 65 | //This is to lower memory usage 66 | posterImage.image = nil 67 | } 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Ookami/Shared/Item View/ItemData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemData.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 1/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | //Protocol to adhere to for transforming to ItemData 12 | protocol ItemDataTransformable { 13 | func toItemData() -> ItemData 14 | } 15 | 16 | //An item data struct 17 | struct ItemData: Equatable { 18 | var name: String? 19 | var details: String? 20 | var moreDetails: String? 21 | var posterImage: String? 22 | var coverImage: String? 23 | 24 | static func ==(lhs: ItemData, rhs: ItemData) -> Bool { 25 | return lhs.name == rhs.name && 26 | lhs.details == rhs.details && 27 | lhs.moreDetails == rhs.moreDetails && 28 | lhs.posterImage == rhs.posterImage && 29 | lhs.coverImage == rhs.coverImage 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Ookami/Shared/Item View/ItemUpdatable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemUpdatable.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 2/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol ItemUpdatable { 12 | /// Update class based on the given data 13 | /// 14 | /// - Parameters: 15 | /// - data: The item data 16 | /// - loadImages: Whether to load the images 17 | func update(data: ItemData, loadImages: Bool) 18 | 19 | //Called when item is out of view 20 | func stopUpdating() 21 | } 22 | -------------------------------------------------------------------------------- /Ookami/Shared/Item View/ItemViewSizeHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ItemViewSizeHandler.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 1/4/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | struct ItemViewSizeHandler { 12 | 13 | private init() {} 14 | 15 | //The sizes of the posters 16 | static private func posterSizes() -> [CGSize] { 17 | //The ratio of the poster (width / height) 18 | let posterRatio: Double = 100 / 150 19 | 20 | //If we have an ipad then make the posters bigger 21 | let isIpad = UIDevice.current.userInterfaceIdiom == .pad 22 | let minWidth = isIpad ? 150 : 100 23 | let maxWidth = isIpad ? 190 : 140 24 | 25 | //We need to work out heights from the given widths 26 | let widths = stride(from: minWidth, to: maxWidth, by: 5) 27 | let sizes = widths.map { width -> CGSize in 28 | let dWidth = Double(width) 29 | let height = floor((1 / posterRatio) * dWidth) 30 | return CGSize(width: dWidth, height: height) 31 | } 32 | 33 | return sizes 34 | } 35 | 36 | //Get the item sizes for a given cell type in ItemViewController 37 | static func itemSizes(for type: ItemViewController.CellType) -> [CGSize] { 38 | let posterSizes = self.posterSizes() 39 | 40 | switch type { 41 | case .detailGrid: 42 | 43 | //We need to add 25 to accomodate for the detail label at the bottom 44 | return posterSizes.map { size -> CGSize in 45 | return CGSize(width: size.width, height: size.height + 25) 46 | } 47 | case .simpleGrid: 48 | //Since the poster fills the whole view, we just pass in the computed sizes 49 | return posterSizes 50 | } 51 | 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Ookami/Shared/Views/GradientView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GradientView.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 2/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | //A view that has a gradient background 12 | @IBDesignable class GradientView: UIView { 13 | @IBInspectable var topColor: UIColor = UIColor.clear { 14 | didSet{ layoutSubviews() } 15 | } 16 | @IBInspectable var bottomColor: UIColor = UIColor.black { 17 | didSet { layoutSubviews() } 18 | } 19 | @IBInspectable var startPoint: CGPoint = CGPoint(x: 0.5, y: 0.0) { 20 | didSet { layoutSubviews() } 21 | } 22 | @IBInspectable var endPoint: CGPoint = CGPoint(x: 0.5, y: 1.0) { 23 | didSet { layoutSubviews() } 24 | } 25 | 26 | override class var layerClass: AnyClass { 27 | return CAGradientLayer.self 28 | } 29 | 30 | override func layoutSubviews() { 31 | (layer as! CAGradientLayer).startPoint = startPoint 32 | (layer as! CAGradientLayer).endPoint = endPoint 33 | (layer as! CAGradientLayer).colors = [topColor.cgColor, bottomColor.cgColor] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Ookami/Shared/Views/NibLoadableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NibLoadableView.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class NibLoadableView: UIView { 12 | 13 | @IBOutlet weak var view: UIView! 14 | 15 | override init(frame: CGRect) { 16 | super.init(frame: frame) 17 | nibSetup() 18 | } 19 | 20 | required init?(coder aDecoder: NSCoder) { 21 | super.init(coder: aDecoder) 22 | nibSetup() 23 | } 24 | 25 | private func nibSetup() { 26 | backgroundColor = .clear 27 | 28 | view = loadViewFromNib() 29 | view.frame = bounds 30 | view.autoresizingMask = [.flexibleWidth, .flexibleHeight] 31 | view.translatesAutoresizingMaskIntoConstraints = true 32 | 33 | addSubview(view) 34 | } 35 | 36 | private func loadViewFromNib() -> UIView { 37 | let bundle = Bundle(for: type(of: self)) 38 | let nib = UINib(nibName: String(describing: type(of: self)), bundle: bundle) 39 | let nibView = nib.instantiate(withOwner: self, options: nil).first as! UIView 40 | 41 | return nibView 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Ookami/Shared/Views/TitleTableSectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TitleTableSectionView.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 13/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Reusable 11 | 12 | class TitleTableSectionView: NibLoadableView { 13 | 14 | @IBOutlet weak var titleLabel: UILabel! 15 | 16 | } 17 | -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/book.png -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/default-cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/default-cover.png -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/default-poster.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/default-poster.jpg -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/kitsu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/kitsu.png -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/ookami-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/ookami-icon.png -------------------------------------------------------------------------------- /Ookami/Supporting Files/Images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Supporting Files/Images/search.png -------------------------------------------------------------------------------- /Ookami/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.4.1 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleURLSchemes 23 | 24 | fb325314560922421 25 | 26 | 27 | 28 | CFBundleVersion 29 | 1 30 | FacebookAppID 31 | 325314560922421 32 | FacebookDisplayName 33 | Kitsu 34 | ITSAppUsesNonExemptEncryption 35 | 36 | LSApplicationQueriesSchemes 37 | 38 | org-appextension-feature-password-management 39 | fbapi 40 | fb-messenger-api 41 | fbauth2 42 | fbshareextension 43 | 44 | LSRequiresIPhoneOS 45 | 46 | NSAppTransportSecurity 47 | 48 | NSAllowsArbitraryLoads 49 | 50 | 51 | UILaunchStoryboardName 52 | LaunchScreen 53 | UIRequiredDeviceCapabilities 54 | 55 | armv7 56 | 57 | UIStatusBarStyle 58 | UIStatusBarStyleLightContent 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationPortrait 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | UIInterfaceOrientationPortraitUpsideDown 65 | 66 | UISupportedInterfaceOrientations~ipad 67 | 68 | UIInterfaceOrientationPortrait 69 | UIInterfaceOrientationPortraitUpsideDown 70 | UIInterfaceOrientationLandscapeLeft 71 | UIInterfaceOrientationLandscapeRight 72 | 73 | UIViewControllerBasedStatusBarAppearance 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /Ookami/Trending/Seasonal View/Data sources/AnimeSeasonalDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeSeasonalDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 10/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class AnimeSeasonalDataSource: AnimeYearTrendingDataSource, SeasonalTrendingDataSource { 13 | 14 | //The current season 15 | var currentSeason: AnimeFilter.Season? 16 | 17 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService? { 18 | guard currentSeason != nil else { return nil } 19 | 20 | return super.paginatedService(completion) 21 | } 22 | 23 | //Get the filter with the current year and season applied 24 | override func filter() -> AnimeFilter { 25 | let season = currentSeason ?? .spring 26 | 27 | let filter = initialFilter.copy() 28 | filter.filter(key: "season_year", value: currentYear) 29 | filter.seasons = [season] 30 | 31 | return filter 32 | } 33 | 34 | func didSet(season: AnimeFilter.Season) { 35 | if currentSeason != season { 36 | currentSeason = season 37 | updateService() 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Ookami/Trending/Seasonal View/SeasonalTrendingViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SeasonalTrendingViewController.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 10/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import ActionSheetPicker_3_0 11 | import OokamiKit 12 | 13 | protocol SeasonalTrendingDataSource: YearTrendingDataSource { 14 | func didSet(season: AnimeFilter.Season) 15 | } 16 | 17 | //A View controller which presents seasonal data 18 | //Subclasses from `YearTrendingViewController` because we also associate a year with the season 19 | class SeasonalTrendingViewController: YearTrendingViewController { 20 | 21 | //The data source converted 22 | fileprivate var seasonalDataSource: SeasonalTrendingDataSource? { 23 | return dataSource as? SeasonalTrendingDataSource 24 | } 25 | 26 | //The selected season 27 | var selectedSeason: AnimeFilter.Season { 28 | didSet { 29 | seasonalDataSource?.didSet(season: selectedSeason) 30 | } 31 | } 32 | 33 | /// Make a seasonal trending view controller. 34 | /// 35 | /// - Parameters: 36 | /// - initialSeason: The initial season. 37 | /// - initialYear: The initial year. Limitation: 1907 < selectedYear < [Current Year] 38 | /// - dataSource: The data source to use 39 | init(initialSeason: AnimeFilter.Season, initialYear: Int, dataSource: SeasonalTrendingDataSource) { 40 | self.selectedSeason = initialSeason 41 | super.init(title: "", initialYear: initialYear, dataSource: dataSource) 42 | 43 | set(season: initialSeason) 44 | } 45 | 46 | required init?(coder aDecoder: NSCoder) { 47 | fatalError("init(coder:) has not been implemented") 48 | } 49 | 50 | func set(season: AnimeFilter.Season) { 51 | self.selectedSeason = season 52 | updateTitle() 53 | } 54 | 55 | override func updateTitle() { 56 | let season = selectedSeason.rawValue.capitalized 57 | let year = selectedYear.description 58 | self.title = season + " " + year 59 | } 60 | 61 | override func yearTapped() { 62 | 63 | let seasons = AnimeFilter.Season.all.map { $0.rawValue.capitalized } 64 | let currentSeasonIndex = AnimeFilter.Season.all.index(of: selectedSeason) ?? 0 65 | 66 | let currentYearIndex = years.index(of: selectedYear.description) ?? 0 67 | 68 | ActionSheetMultipleStringPicker.show(withTitle: "Season", rows: [seasons, years], initialSelection: [currentSeasonIndex, currentYearIndex], doneBlock: { picker, indexes, values in 69 | 70 | guard let indexes = indexes as? [Int] else { return } 71 | 72 | let season = AnimeFilter.Season.all[indexes[0]] 73 | self.set(season: season) 74 | 75 | if let year = Int(self.years[indexes[1]]) { 76 | self.set(year: year) 77 | } 78 | }, cancel: nil, origin: yearBarButton) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Ookami/Trending/Shared/PaginatedTrendingViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaginatedTrendingViewController.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 9/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import Cartography 11 | 12 | protocol PaginatedTrendingDataSource: ItemViewControllerDataSource { 13 | weak var parent: UIViewController? { get set } 14 | } 15 | 16 | //A view controller to display paginated trending items 17 | class PaginatedTrendingViewController: UIViewController { 18 | 19 | /// The data source to use 20 | var dataSource: PaginatedTrendingDataSource { 21 | didSet { 22 | itemController.dataSource = dataSource 23 | dataSource.parent = self 24 | } 25 | } 26 | 27 | //The item controller to show the results 28 | fileprivate var itemController: ItemViewController 29 | 30 | /// Make a paginated trending view controller. 31 | /// 32 | /// - Parameters: 33 | /// - dataSource: The data source to use 34 | init(dataSource: PaginatedTrendingDataSource) { 35 | self.dataSource = dataSource 36 | 37 | itemController = ItemViewController(dataSource: dataSource) 38 | itemController.type = .simpleGrid 39 | itemController.shouldLoadImages = true 40 | super.init(nibName: nil, bundle: nil) 41 | 42 | self.dataSource.parent = self 43 | } 44 | 45 | required init?(coder aDecoder: NSCoder) { 46 | fatalError("Use init(dataSource:) instead") 47 | } 48 | 49 | override func viewDidLoad() { 50 | super.viewDidLoad() 51 | 52 | //Add the item view controller and the search bar 53 | self.addChildViewController(itemController) 54 | 55 | self.view.addSubview(itemController.view) 56 | 57 | constrain(itemController.view) { view in 58 | view.edges == view.superview!.edges 59 | } 60 | 61 | itemController.didMove(toParentViewController: self) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/Data sources/AnimeTrendingTableDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeTrendingTableDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 3/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class AnimeTrendingTableDataSource: MediaTrendingTableDataSource { 13 | 14 | //The filter to apply 15 | var filter: AnimeFilter 16 | 17 | /// Create an Anime Trending Data Source 18 | /// 19 | /// - Parameters: 20 | /// - title: The title 21 | /// - detail: The detail 22 | /// - filter: The filter to use for displaying anime 23 | /// - parent: The parent of the data source 24 | /// - delegate: The delegate 25 | /// - onTap: The block which gets called when the see all button is tapped. 26 | init(title: String, detail: String = "", filter: AnimeFilter, parent: UIViewController, delegate: TrendingTableDelegate, onTap: @escaping () -> Void) { 27 | self.filter = filter 28 | super.init(title: title, detail: detail, parent: parent, delegate: delegate, onTap: onTap) 29 | } 30 | 31 | override func fetchMediaIds(_ completion: @escaping ([Int]?, Error?) -> Void) { 32 | DiscoverService().find(type: .anime, title: "", filters: filter, limit: 10) { ids, error, _ in 33 | completion(ids, error) 34 | }.start() 35 | } 36 | 37 | override func itemData(for indexPath: IndexPath) -> ItemData? { 38 | if let anime = Anime.get(withId: mediaIds[indexPath.row]) { 39 | var data = anime.toItemData() 40 | data.details = "" 41 | return data 42 | } 43 | 44 | return nil 45 | } 46 | 47 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 48 | collectionView.deselectItem(at: indexPath, animated: true) 49 | 50 | if let parent = parent, 51 | let anime = Anime.get(withId: mediaIds[indexPath.row]) { 52 | AppCoordinator.showAnimeVC(in: parent, anime: anime) 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/Data sources/AnimeWeeklyTrendingTableDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeWeeklyTrendingTableDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 10/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class AnimeWeeklyTrendingTableDataSource: MediaTrendingTableDataSource { 13 | 14 | /// Create a Weekly Anime Trending Data Source 15 | /// 16 | /// - Parameters: 17 | /// - title: The title 18 | /// - detail: The detail text 19 | /// - parent: The parent 20 | /// - delegate: The delegate 21 | init(title: String, detail: String = "", parent: UIViewController, delegate: TrendingTableDelegate) { 22 | super.init(title: title, detail: detail, parent: parent, delegate: delegate, onTap: {}) 23 | self.showSeeAllButton = false 24 | } 25 | 26 | override func fetchMediaIds(_ completion: @escaping ([Int]?, Error?) -> Void) { 27 | AnimeService().trending(completion: completion) 28 | } 29 | 30 | override func itemData(for indexPath: IndexPath) -> ItemData? { 31 | if let anime = Anime.get(withId: mediaIds[indexPath.row]) { 32 | var data = anime.toItemData() 33 | data.details = "" 34 | return data 35 | } 36 | 37 | return nil 38 | } 39 | 40 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 41 | collectionView.deselectItem(at: indexPath, animated: true) 42 | 43 | if let parent = parent, 44 | let anime = Anime.get(withId: mediaIds[indexPath.row]) { 45 | AppCoordinator.showAnimeVC(in: parent, anime: anime) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/Data sources/MangaTrendingTableDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaTrendingTableDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class MangaTrendingTableDataSource: MediaTrendingTableDataSource { 13 | 14 | //The filter to apply 15 | var filter: MangaFilter 16 | 17 | /// Create an Manga Trending Data Source 18 | /// 19 | /// - Parameters: 20 | /// - title: The title 21 | /// - detail: The detail 22 | /// - filter: The filter to use for displaying manga 23 | /// - parent: The parent of the data source 24 | /// - delegate: The delegate 25 | /// - onTap: The block which gets called when the see all button is tapped. 26 | init(title: String, detail: String = "", filter: MangaFilter, parent: UIViewController, delegate: TrendingTableDelegate, onTap: @escaping () -> Void) { 27 | self.filter = filter 28 | super.init(title: title, detail: detail, parent: parent, delegate: delegate, onTap: onTap) 29 | } 30 | 31 | override func fetchMediaIds(_ completion: @escaping ([Int]?, Error?) -> Void) { 32 | DiscoverService().find(type: .manga, title: "", filters: filter, limit: 10) { ids, error, _ in 33 | completion(ids, error) 34 | }.start() 35 | } 36 | 37 | override func itemData(for indexPath: IndexPath) -> ItemData? { 38 | if let manga = Manga.get(withId: mediaIds[indexPath.row]) { 39 | var data = manga.toItemData() 40 | data.details = "" 41 | return data 42 | } 43 | 44 | return nil 45 | } 46 | 47 | 48 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 49 | collectionView.deselectItem(at: indexPath, animated: true) 50 | 51 | if let parent = parent, 52 | let manga = Manga.get(withId: mediaIds[indexPath.row]) { 53 | AppCoordinator.showMangaVC(in: parent, manga: manga) 54 | } 55 | } 56 | 57 | } 58 | 59 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/Data sources/MangaWeeklyTrendingTableDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaWeeklyTrendingTableDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 10/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class MangaWeeklyTrendingTableDataSource: MediaTrendingTableDataSource { 13 | 14 | /// Create a Weekly Manga Trending Data Source 15 | /// 16 | /// - Parameters: 17 | /// - title: The title 18 | /// - detail: The detail text 19 | /// - parent: The parent 20 | /// - delegate: The delegate 21 | init(title: String, detail: String = "", parent: UIViewController, delegate: TrendingTableDelegate) { 22 | super.init(title: title, detail: detail, parent: parent, delegate: delegate, onTap: {}) 23 | self.showSeeAllButton = false 24 | } 25 | 26 | override func fetchMediaIds(_ completion: @escaping ([Int]?, Error?) -> Void) { 27 | MangaService().trending(completion: completion) 28 | } 29 | 30 | override func itemData(for indexPath: IndexPath) -> ItemData? { 31 | if let manga = Manga.get(withId: mediaIds[indexPath.row]) { 32 | var data = manga.toItemData() 33 | data.details = "" 34 | return data 35 | } 36 | 37 | return nil 38 | } 39 | 40 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 41 | collectionView.deselectItem(at: indexPath, animated: true) 42 | 43 | if let parent = parent, 44 | let manga = Manga.get(withId: mediaIds[indexPath.row]) { 45 | AppCoordinator.showMangaVC(in: parent, manga: manga) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/Data sources/TrendingTableDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TrendingTableDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 3/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | protocol TrendingTableDelegate: class { 12 | func reload(dataSource: TrendingTableDataSource) 13 | } 14 | 15 | class TrendingTableDataSource: NSObject { 16 | 17 | //The title of the cell 18 | var title: String 19 | 20 | //The detail string 21 | var detail: String = "" 22 | 23 | //Whether we want the see all button to be visible 24 | var showSeeAllButton = true 25 | 26 | //The parent of the data source 27 | weak var parent: UIViewController? 28 | 29 | //The delegate for the data source 30 | weak var delegate: TrendingTableDelegate? 31 | 32 | //The layout that will be applied to the collection view 33 | var collectionViewLayout: UICollectionViewFlowLayout { 34 | let layout = UICollectionViewFlowLayout() 35 | layout.scrollDirection = .horizontal 36 | return layout 37 | } 38 | 39 | init(title: String, detail: String = "", parent: UIViewController, delegate: TrendingTableDelegate) { 40 | self.title = title 41 | self.detail = detail 42 | self.parent = parent 43 | self.delegate = delegate 44 | } 45 | 46 | //Setup the collection view 47 | func setup(collectionView: UICollectionView) { 48 | } 49 | 50 | //Reload the data 51 | func reload() { 52 | delegate?.reload(dataSource: self) 53 | } 54 | 55 | //The see all button was tapped 56 | func didTapSeeAllButton() { 57 | } 58 | 59 | } 60 | 61 | //MARK:- UICollectionViewDataSource 62 | extension TrendingTableDataSource: UICollectionViewDataSource { 63 | func numberOfSections(in collectionView: UICollectionView) -> Int { 64 | return 1 65 | } 66 | 67 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 68 | return 0 69 | } 70 | 71 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 72 | return UICollectionViewCell() 73 | } 74 | } 75 | 76 | //MARK:- UICollectionViewDelegate 77 | extension TrendingTableDataSource: UICollectionViewDelegate {} 78 | -------------------------------------------------------------------------------- /Ookami/Trending/Table View/MangaTrendingTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaTrendingTableViewController.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class MangaTrendingTableViewController: TrendingTableViewController { 13 | 14 | override func viewDidLoad() { 15 | super.viewDidLoad() 16 | 17 | self.data = [weeklyTrending(), highestRatedFilter(), popularityFilter()] 18 | self.tableView.reloadData() 19 | } 20 | 21 | //MARK:- Weekly Trending 22 | private func weeklyTrending() -> MangaWeeklyTrendingTableDataSource { 23 | return MangaWeeklyTrendingTableDataSource(title: "Trending", detail: "This Week", parent: self, delegate: self) 24 | } 25 | 26 | //MARK:- Highest Rated 27 | private func highestRatedFilter() -> MangaTrendingTableDataSource { 28 | //For Manga since there is no such thing as seasons, we just directly choose the current year 29 | let year = Calendar.current.component(.year, from: Date()) 30 | let title = "Highest Rated Manga" 31 | let detail = year.description 32 | 33 | let filter = MangaFilter() 34 | filter.year.start = year 35 | filter.year.end = year 36 | filter.sort = Sort(by: .averageRating) 37 | 38 | return MangaTrendingTableDataSource(title: title, detail: detail, filter: filter, parent: self, delegate: self) { [weak self] in 39 | let source = MangaYearTrendingDataSource(filter: filter) 40 | let yearController = YearTrendingViewController(title: "Highest Rated", initialYear: year, dataSource: source) 41 | self?.navigationController?.pushViewController(yearController, animated: true) 42 | } 43 | 44 | } 45 | 46 | //MARK:- Popularity 47 | private func popularityFilter() -> MangaTrendingTableDataSource { 48 | //For Manga since there is no such thing as seasons, we just directly choose the current year 49 | let year = Calendar.current.component(.year, from: Date()) 50 | let title = "Most Popular Manga" 51 | let detail = year.description 52 | 53 | let filter = MangaFilter() 54 | filter.year.start = year 55 | filter.year.end = year 56 | filter.sort = Sort(by: .popularity) 57 | 58 | return MangaTrendingTableDataSource(title: title, detail: detail, filter: filter, parent: self, delegate: self) { [weak self] in 59 | let source = MangaYearTrendingDataSource(filter: filter.copy()) 60 | let yearController = YearTrendingViewController(title: "Most Popular", initialYear: year, dataSource: source) 61 | self?.navigationController?.pushViewController(yearController, animated: true) 62 | } 63 | 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Ookami/Trending/Year View/Data sources/AnimeYearTrendingDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeYearTrendingDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class AnimeYearTrendingDataSource: MediaYearTrendingDataSource { 13 | 14 | //The filter we are going to use 15 | var initialFilter: AnimeFilter 16 | 17 | /// Create an Anime Year Trending Data Source 18 | /// 19 | /// - Parameter filter: The initial filter to use. 20 | init(filter: AnimeFilter = AnimeFilter()) { 21 | self.initialFilter = filter.copy() 22 | } 23 | 24 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService? { 25 | 26 | guard currentYear > 0 else { return nil } 27 | 28 | return service(for: filter(), completion: completion) 29 | } 30 | 31 | //Get the filter with the current year applied 32 | func filter() -> AnimeFilter { 33 | let filter = initialFilter.copy() 34 | filter.year = RangeFilter(start: currentYear, end: currentYear) 35 | return filter 36 | } 37 | 38 | func service(for filter: AnimeFilter, completion: @escaping () -> Void) -> PaginatedService { 39 | return DiscoverService().find(type: .anime, title: "", filters: filter) { [weak self] ids, error, original in 40 | 41 | completion() 42 | 43 | guard error == nil, 44 | let ids = ids else { 45 | if error as? PaginationError != nil { 46 | //Don't print anything if it's pagination related 47 | return 48 | } 49 | 50 | print(error!.localizedDescription) 51 | return 52 | } 53 | 54 | //We should return the results in order they were recieved so that users can get the best results 55 | let anime = ids.flatMap { Anime.get(withId: $0) } 56 | self?.updateItemData(from: anime, original: original) 57 | 58 | //If the device is an ipad and it's the original then we fetch the next page so that content is filled up on the screen 59 | if UIDevice.current.userInterfaceIdiom == .pad && original { 60 | self?.loadMore() 61 | } 62 | } 63 | } 64 | 65 | //MARK:- ItemDataSource 66 | override func didSelectItem(at indexPath: IndexPath) { 67 | if let parent = parent, 68 | let anime = self.data[indexPath.row] as? Anime { 69 | AppCoordinator.showAnimeVC(in: parent, anime: anime) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Ookami/Trending/Year View/Data sources/MangaYearTrendingDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaYearTrendingDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | class MangaYearTrendingDataSource: MediaYearTrendingDataSource { 13 | 14 | //The filter we are going to use 15 | var initialFilter: MangaFilter 16 | 17 | /// Create an Manga Year Trending Data Source 18 | /// 19 | /// - Parameter filter: The initial filter to use. 20 | init(filter: MangaFilter = MangaFilter()) { 21 | self.initialFilter = filter.copy() 22 | } 23 | 24 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService? { 25 | guard currentYear > 0 else { return nil } 26 | return service(for: filter(), completion: completion) 27 | } 28 | 29 | //Get the filter with the current year applied 30 | func filter() -> MangaFilter { 31 | let filter = initialFilter.copy() 32 | filter.year = RangeFilter(start: currentYear, end: currentYear) 33 | return filter 34 | } 35 | 36 | func service(for filter: MangaFilter, completion: @escaping () -> Void) -> PaginatedService { 37 | return DiscoverService().find(type: .manga, title: "", filters: filter) { [weak self] ids, error, original in 38 | 39 | completion() 40 | 41 | guard error == nil, 42 | let ids = ids else { 43 | if error as? PaginationError != nil { 44 | //Don't print anything if it's pagination related 45 | return 46 | } 47 | 48 | print(error!.localizedDescription) 49 | return 50 | } 51 | 52 | //We should return the results in order they were recieved so that users can get the best results 53 | let manga = ids.flatMap { Manga.get(withId: $0) } 54 | self?.updateItemData(from: manga, original: original) 55 | 56 | //If the device is an ipad and it's the original then we fetch the next page so that content is filled up on the screen 57 | if UIDevice.current.userInterfaceIdiom == .pad && original { 58 | self?.loadMore() 59 | } 60 | } 61 | } 62 | 63 | //MARK:- ItemDataSource 64 | override func didSelectItem(at indexPath: IndexPath) { 65 | if let parent = parent, 66 | let manga = self.data[indexPath.row] as? Manga { 67 | AppCoordinator.showMangaVC(in: parent, manga: manga) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Ookami/Trending/Year View/Data sources/MediaYearTrendingDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaYearTrendingDataSource.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/3/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import OokamiKit 11 | 12 | /// A data source for media year trends 13 | /// Must be subclassed 14 | class MediaYearTrendingDataSource: PaginatedItemViewDataSourceBase, YearTrendingDataSource { 15 | 16 | //The parent 17 | weak var parent: UIViewController? 18 | 19 | //The current year text 20 | var currentYear: Int = -1 21 | 22 | //The paginated service 23 | //Also expose the method here so we know what needs to be overriden without going to the other classes. 24 | override func paginatedService(_ completion: @escaping () -> Void) -> PaginatedService? { 25 | fatalError("paginatedService(completion:) needs to be implemented in a subclass") 26 | } 27 | 28 | /// Update the trending results. 29 | /// This will discard the previous service and fetch a whole new one. 30 | /// 31 | /// - Parameter year: The year 32 | func update(year: Int) { 33 | guard year > 0 else { return } 34 | 35 | //Set the current year 36 | currentYear = year 37 | 38 | updateService() 39 | } 40 | 41 | func didSet(year: Int) { 42 | if currentYear != year { 43 | update(year: year) 44 | } 45 | } 46 | 47 | override func dataSetImage() -> UIImage? { 48 | let size = CGSize(width: 46, height: 44) 49 | let color = UIColor.lightGray.lighter(amount: 0.1) 50 | return UIImage(named: "Trending_tab_bar")? 51 | .resize(size) 52 | .color(color) 53 | } 54 | 55 | override func dataSetTitle() -> NSAttributedString? { 56 | let title = "Could not find any results." 57 | let attributes = [NSFontAttributeName: UIFont.systemFont(ofSize: 16), 58 | NSForegroundColorAttributeName: UIColor.lightGray.lighter(amount: 0.1)] 59 | return NSAttributedString(string: title, attributes: attributes) 60 | } 61 | 62 | 63 | } 64 | 65 | -------------------------------------------------------------------------------- /Ookami/Vendor/Iconic/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ignacio Romero Zurbuchen iromero@dzen.cl 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /Ookami/Vendor/Iconic/Source/Catalog/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/Ookami/Vendor/Iconic/Source/Catalog/FontAwesome.otf -------------------------------------------------------------------------------- /Ookami/Vendor/Iconic/Source/Catalog/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 | Icon Font Reference 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Ookami/Vendor/Iconic/Source/Catalog/script.js: -------------------------------------------------------------------------------- 1 | 2 | $(document).ready(function(){ 3 | $.getJSON("data.json", function(data) { 4 | importFont(data); 5 | buildHeader(data); 6 | buildList(data); 7 | buildFooter(); 8 | }); 9 | }) 10 | 11 | function importFont(data){ 12 | var font_name = data["name"]; 13 | var file_name = data["filename"]; 14 | $("head").append($('')); 15 | } 16 | 17 | function buildHeader(data){ 18 | var font_name = data["name"]; 19 | var unicodes = data["unicodes"]; 20 | var title = font_name + " Catalog"; 21 | 22 | // Document title 23 | document.title = title; 24 | 25 | // Title and subtitle 26 | $("body").append($('
'+title+'
')); 27 | $("body").append($('
'+Object.keys(unicodes).length+' Icons Available
')); 28 | } 29 | 30 | function buildList(data){ 31 | var font_name = data["name"]; 32 | var unicodes = sortObject(data["unicodes"]); 33 | 34 | var newUl = $('
    '); 35 | 36 | // Appends each icon row by row 37 | for(var key in unicodes){ 38 | var icon = '&#x' + unicodes[key]; 39 | var unicode = '0x' + unicodes[key]; 40 | 41 | var newLi = $('
  • '); 42 | newLi.addClass('icon'); 43 | newLi.append('
    ' + icon + '
    '); 44 | newLi.append('' + camelize(key + "Icon") + ''); 45 | newLi.append('' + unicode + ''); 46 | 47 | newUl.append(newLi); 48 | } 49 | 50 | $("body").append(newUl); 51 | } 52 | 53 | function camelize(str) { 54 | return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) { 55 | return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); 56 | }).replace(/\s+/g, ''); 57 | } 58 | 59 | function sortObject(obj) { 60 | return Object.keys(obj).sort().reduce(function (result, key) { 61 | result[key] = obj[key]; 62 | return result; 63 | }, {}); 64 | } 65 | 66 | function buildFooter(){ 67 | // Footer 68 | $("body").append($('')); 69 | } 70 | -------------------------------------------------------------------------------- /Ookami/Vendor/Iconic/Source/Catalog/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-style: normal; 3 | font-weight: 100; 4 | } 5 | 6 | /* General layout */ 7 | body { 8 | margin: 40px; 9 | font-size: 22px; 10 | font-family: Helvetica, sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | } 13 | 14 | div { 15 | padding: 0 0 15px; 16 | } 17 | 18 | ul { 19 | clear: both; 20 | overflow: hidden; 21 | padding: 0 0 5px; 22 | margin: 30px 0 50px; 23 | list-style-type: none; 24 | } 25 | 26 | li { 27 | float: left; 28 | width: 150px; 29 | height: 130px; 30 | border: 1px solid #DDD; 31 | border-radius: 2px; 32 | margin: 0 10px 10px 0; 33 | text-align: center; 34 | position: relative; 35 | } 36 | 37 | li i { 38 | position: absolute; 39 | bottom: 4px; 40 | left: 4px; 41 | right: 4px; 42 | background: #AAA; 43 | min-height: 14px; 44 | padding: 4px 0; 45 | font-style: normal; 46 | font-weight: 400; 47 | font-size: 16px; 48 | color: #fff; 49 | border-radius: 2px; 50 | } 51 | 52 | li:hover { 53 | background-color: #f2f2f2; 54 | } 55 | 56 | code { 57 | display: block; 58 | font-size: 14px; 59 | margin: 10px; 60 | color: #888; 61 | } 62 | 63 | .title { 64 | font-size: 30px; 65 | font-weight: 500; 66 | } 67 | 68 | .subtitle { 69 | font-size: 18px; 70 | font-weight: 200; 71 | color: #888; 72 | } 73 | 74 | .icon { 75 | font-size: 50px; 76 | padding: 4px 0; 77 | color: #000; 78 | } 79 | 80 | .footer { 81 | font-size: 15px; 82 | text-align: center; 83 | font-weight: 200; 84 | color: #888; 85 | } 86 | 87 | a { 88 | color: #0e9df2; 89 | text-decoration: none; 90 | } 91 | 92 | a:hover { 93 | color: #86cef8; 94 | text-decoration: none; 95 | } 96 | -------------------------------------------------------------------------------- /Ookami/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // main.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 3/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | //Use MockAppDelegate if we are unit testing 12 | //This is to avoid the initial view controller to have effects on the tests 13 | 14 | final class MockAppDelegate: UIResponder, UIApplicationDelegate {} 15 | 16 | private func appDelegateClassName() -> String { 17 | let isTesting = NSClassFromString("XCTestCase") != nil 18 | return 19 | NSStringFromClass(isTesting ? MockAppDelegate.self : AppDelegate.self) 20 | } 21 | 22 | UIApplicationMain( 23 | CommandLine.argc, 24 | UnsafeMutableRawPointer(CommandLine.unsafeArgv) 25 | .bindMemory(to: UnsafeMutablePointer.self, capacity: Int(CommandLine.argc)), 26 | NSStringFromClass(UIApplication.self), appDelegateClassName() 27 | ) 28 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 26/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum Constants {} 12 | 13 | /// Constants for the app 14 | extension Constants { 15 | 16 | /// Urls that we use 17 | enum URL { 18 | static let kitsu = "https://kitsu.io" 19 | static let api = "\(kitsu)/api/edge" 20 | static let authToken = "\(kitsu)/api/oauth/token" 21 | } 22 | 23 | //API endpoints 24 | enum Endpoints { 25 | static let users = "/users" 26 | static let libraryEntries = "/library-entries" 27 | static let anime = "/anime" 28 | static let manga = "/manga" 29 | static let genres = "/genres" 30 | static let trending = "/trending" 31 | static let mediaRelationships = "/media-relationships" 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/API/AnimeService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeService.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 17/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class AnimeService: BaseService { 12 | 13 | /// Get the weekly trending anime. 14 | /// 15 | /// - Parameter completion: The completion block which passes back an array of trending anime ids or an error if something went wrong. 16 | public func trending(completion: @escaping ([Int]?, Error?) -> Void) { 17 | let endpoint = Constants.Endpoints.trending 18 | let request = KitsuRequest(relativeURL: endpoint + "/anime") 19 | 20 | let operation = NetworkOperation(request: request.build(), client: client) { json, error in 21 | guard error == nil else { 22 | completion(nil, error) 23 | return 24 | } 25 | 26 | guard let json = json else { 27 | completion(nil, ServiceError.error(description: "Invalid JSON recieved - Anime Trending GET")) 28 | return 29 | } 30 | 31 | Parser().parse(json: json) { parsed in 32 | self.database.addOrUpdate(parsed) 33 | 34 | //Get the anime 35 | let anime = parsed.filter { $0 is Anime } as? [Anime] ?? [] 36 | let ids = anime.map { $0.id } 37 | completion(ids, nil) 38 | } 39 | 40 | } 41 | 42 | queue.addOperation(operation) 43 | } 44 | 45 | /// Get an anime with the given id. 46 | /// 47 | /// - Parameters: 48 | /// - id: The anime id. 49 | /// - completion: A completion block which passes back the anime object or and error if something went wrong 50 | public func get(id: Int, completion: @escaping (Anime?, Error?) -> Void) { 51 | let endpoint = Constants.Endpoints.anime 52 | let request = KitsuRequest(relativeURL: "\(endpoint)/\(id)") 53 | request.include("genres") 54 | 55 | let operation = NetworkOperation(request: request.build(), client: client) { json, error in 56 | guard error == nil else { 57 | completion(nil, error) 58 | return 59 | } 60 | 61 | guard let json = json else { 62 | completion(nil, ServiceError.error(description: "Invalid JSON recieved - Anime Service GET")) 63 | return 64 | } 65 | 66 | Parser().parse(json: json) { parsed in 67 | self.database.addOrUpdate(parsed) 68 | 69 | //Get the anime we parsed 70 | let anime = parsed.first { $0 is Anime } as? Anime 71 | completion(anime, nil) 72 | } 73 | 74 | } 75 | 76 | queue.addOperation(operation) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/API/BaseService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BaseService.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 24/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class BaseService { 12 | 13 | public enum ServiceError: Error { 14 | case error(description: String) 15 | case notAuthenticated 16 | } 17 | 18 | ///The database to use 19 | public var database: Database = Database() 20 | 21 | ///The current user class 22 | public var currentUser: CurrentUser = CurrentUser() 23 | 24 | //The operation queue to use 25 | public internal(set) var queue: OperationQueue 26 | 27 | //The network client 28 | public internal(set) var client: NetworkClient 29 | 30 | /// Create a api class 31 | /// 32 | /// - Parameters: 33 | /// - queue: The operation queue 34 | /// - client: The network client 35 | public init(queue: OperationQueue = Ookami.shared.queue, client: NetworkClient = Ookami.shared.networkClient, currentUser: CurrentUser = CurrentUser()) { 36 | self.queue = queue 37 | self.client = client 38 | self.currentUser = currentUser 39 | } 40 | } 41 | 42 | 43 | extension BaseService.ServiceError: LocalizedError { 44 | public var errorDescription: String? { 45 | switch self { 46 | case .error(let description): 47 | return description 48 | case .notAuthenticated: 49 | return "User is not authenticated!" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/API/MangaService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaService.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 17/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class MangaService: BaseService { 12 | 13 | /// Get the weekly trending manga. 14 | /// 15 | /// - Parameter completion: The completion block which passes back an array of trending manga ids or an error if something went wrong. 16 | public func trending(completion: @escaping ([Int]?, Error?) -> Void) { 17 | let endpoint = Constants.Endpoints.trending 18 | let request = KitsuRequest(relativeURL: endpoint + "/manga") 19 | 20 | let operation = NetworkOperation(request: request.build(), client: client) { json, error in 21 | guard error == nil, 22 | let json = json else { 23 | completion(nil, error) 24 | return 25 | } 26 | 27 | Parser().parse(json: json) { parsed in 28 | self.database.addOrUpdate(parsed) 29 | 30 | //Get the anime 31 | let manga = parsed.filter { $0 is Manga } as? [Manga] ?? [] 32 | let ids = manga.map { $0.id } 33 | completion(ids, nil) 34 | } 35 | 36 | } 37 | 38 | queue.addOperation(operation) 39 | } 40 | 41 | 42 | /// Get a manga with the given id. 43 | /// 44 | /// - Parameters: 45 | /// - id: The manga id. 46 | /// - completion: A completion block which passes back the manga object or and error if something went wrong 47 | public func get(id: Int, completion: @escaping (Manga?, Error?) -> Void) { 48 | let endpoint = Constants.Endpoints.manga 49 | let request = KitsuRequest(relativeURL: "\(endpoint)/\(id)") 50 | request.include("genres") 51 | 52 | let operation = NetworkOperation(request: request.build(), client: client) { json, error in 53 | guard error == nil, 54 | let json = json else { 55 | completion(nil, error) 56 | return 57 | } 58 | 59 | Parser().parse(json: json) { parsed in 60 | self.database.addOrUpdate(parsed) 61 | 62 | //Get the user we parsed 63 | let manga = parsed.first { $0 is Manga } as? Manga 64 | completion(manga, nil) 65 | } 66 | 67 | } 68 | 69 | queue.addOperation(operation) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/API/MediaService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaService.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 30/8/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | public class MediaService: BaseService { 13 | 14 | /// Get the media relationships for the given media. 15 | /// 16 | /// - Parameters: 17 | /// - id: The media id. 18 | /// - type: The media type. 19 | /// - completion: The completion block which passes an array of the media relationship ids or an error if it occurred. 20 | /// - Returns: The paginated service. 21 | @discardableResult public func getMediaRelationships(for id: Int, type: Media.MediaType, completion: @escaping ([Int]?, Error?) -> Void) -> PaginatedService { 22 | let endpoint = Constants.Endpoints.mediaRelationships 23 | let request = KitsuPagedRequest(relativeURL: endpoint) 24 | request.filter(key: "source_id", value: id) 25 | request.filter(key: "source_type", value: type.rawValue.capitalized) 26 | request.include("destination", "source") 27 | request.page = KitsuPagedRequest.Page(offset: 0, limit: 20) 28 | 29 | let paginated = PaginatedService(request: request, client: client) { parsed, error, original in 30 | guard error == nil else { 31 | completion(nil, error) 32 | return 33 | } 34 | 35 | guard let parsed = parsed else { 36 | completion(nil, NetworkClientError.error("Failed to get parsed objects - Media Service")) 37 | return 38 | } 39 | 40 | //Add the objects to the database 41 | self.database.addOrUpdate(parsed) 42 | 43 | //Filter the MediaReplationships out of the parsed objects and return it 44 | let filtered = parsed.filter { $0 is MediaRelationship } as! [MediaRelationship] 45 | completion(filtered.map { $0.id }, nil) 46 | } 47 | 48 | paginated.start() 49 | 50 | return paginated 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/CurrentUser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentUser.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 1/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Heimdallr 11 | 12 | //A class that is used to store current user data. 13 | public class CurrentUser { 14 | 15 | //Notifications to send 16 | public enum Notifications: String { 17 | case userLoggedIn 18 | case userLoggedOut 19 | 20 | public var name: Notification.Name { 21 | return Notification.Name(rawValue: self.rawValue) 22 | } 23 | } 24 | 25 | /// The heimdallr class used for OAuth2 authentication 26 | let heimdallr: Heimdallr 27 | 28 | /// The key used to store the username in user defaults 29 | let userIDKey: String 30 | 31 | /// The id of the user that is logged in, nil if not logged in 32 | public internal(set) var userID: Int? { 33 | get { 34 | return UserDefaults.standard.object(forKey: self.userIDKey) as? Int 35 | } 36 | 37 | set(id) { 38 | if id != nil { 39 | UserDefaults.standard.set(id, forKey: self.userIDKey) 40 | send(notification: .userLoggedIn) 41 | } else { 42 | UserDefaults.standard.removeObject(forKey: self.userIDKey) 43 | } 44 | } 45 | } 46 | 47 | //The user object of the currently logged in user, nil if not logged in 48 | public var user: User? { 49 | guard let id = userID else { 50 | return nil 51 | } 52 | return User.get(withId: id) 53 | } 54 | 55 | /// Create a class for storing user data 56 | /// 57 | /// - Parameters: 58 | /// - heimdallr: The heimdallr instance which is used for `logout` and `isLoggedIn` 59 | /// - userIDKey: A string key which is used for storing the user id. 60 | public init(heimdallr: Heimdallr = Ookami.shared.heimdallr, userIDKey: String = "kitsu_loggedin_user") { 61 | self.heimdallr = heimdallr 62 | self.userIDKey = userIDKey 63 | } 64 | 65 | /// Logout the current user 66 | public func logout() { 67 | heimdallr.clearAccessToken() 68 | userID = nil 69 | send(notification: .userLoggedOut) 70 | } 71 | 72 | /// Check if a user is logged in 73 | /// Note: This will only return true if we have a token, not if we have stored the user id 74 | /// 75 | /// - Returns: True or false if user is logged in 76 | public func isLoggedIn() -> Bool { 77 | return heimdallr.hasAccessToken 78 | } 79 | 80 | /// Send a notification 81 | /// 82 | /// - Parameter notification: The notification to send 83 | private func send(notification: Notifications) { 84 | NotificationCenter.default.post(name: notification.name, object: nil) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/Filters/MangaFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaFilter.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | //A class for representing Manga filters 12 | public class MangaFilter: MediaFilter { 13 | 14 | //The subtypes to filter 15 | public var subtypes: [Manga.SubType] = [] 16 | 17 | public override func construct() -> [String : Any] { 18 | var dict = super.construct() 19 | 20 | //Subtype 21 | if subtypes.count > 0 { 22 | dict["subtype"] = subtypes.map { $0.rawValue }.joined(separator: ",") 23 | } 24 | 25 | return dict 26 | } 27 | 28 | /// Create a copy of the manga filter. 29 | /// 30 | /// - Returns: The copied manga filter 31 | public func copy() -> MangaFilter { 32 | let m = MangaFilter() 33 | m.year = self.year 34 | m.rating = self.rating 35 | m.genres = self.genres 36 | m.subtypes = self.subtypes 37 | m.sort = self.sort 38 | m.additionalFilters = self.additionalFilters 39 | return m 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/Filters/RangeFilter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeFilter.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | //A Struct representing a range filter 12 | public struct RangeFilter: CustomStringConvertible where T: CustomStringConvertible { 13 | public var start: T 14 | public var end: T? 15 | 16 | public init(start: T, end: T?) { 17 | self.start = start 18 | self.end = end 19 | } 20 | 21 | //Apply correction to the values so that start < end 22 | mutating func applyCorrection() { 23 | //Check that start < end 24 | if let end = self.end { 25 | let start = self.start 26 | 27 | self.start = min(start, end) 28 | self.end = max(start, end) 29 | } 30 | } 31 | 32 | //Cap the start and end values to the min and max 33 | mutating func capValues(min minValue: T, max maxValue: T) { 34 | self.start = min(maxValue, max(minValue, self.start)) 35 | 36 | if let end = self.end { 37 | self.end = min(maxValue, max(minValue, end)) 38 | } 39 | } 40 | 41 | public var description: String { 42 | let end = self.end?.description ?? "" 43 | return "\(start)..\(end)" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/Pagination/PaginatedLibrary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PaginatedLibrary.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 13/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | import RealmSwift 12 | 13 | /* 14 | The reason we have a paginated library class is because users won't look at ALL of another users library if you think about it. 15 | 16 | Say a user `A` looks at user `B`'s completed library which has 1000 entries. `A` won't bother looking through all 1000 entries of `B`, thus we can save time and data usage by adding in pagination, which is already supported by the api. This makes it so user `A` can still look at user `B`'s completed library, but if they wish to view more we just fetch the next page from the server for them. 17 | 18 | However we still need to be able to fetch a full users library regardless of pagination, thus the FetchLibraryOperation still exists. This is needed for the current user using the app. We need to reliably be able to sync between the website and the app (e.g if user deletes entry on website, then it should be deleted in app) and the only way to do that is to fetch the users whole library. 19 | */ 20 | 21 | /// Class for fetching a user's library paginated from the server. 22 | public class PaginatedLibrary: PaginatedService { 23 | 24 | /// Create a paginated library. 25 | /// call `start()` to begin the fetch 26 | /// 27 | /// - Parameters: 28 | /// - request: The paged kitsu library request 29 | /// - client: The client to execute request on 30 | /// - completion: The completion block, returns the fetched entries and related objects on the current page, or an Error if something went wrong. 31 | /// This gets called everytime a page of entries is recieved. 32 | /// This can be through calls such as `next()`, `prev()` etc ... 33 | public init(request: KitsuLibraryRequest, client: NetworkClientProtocol, completion: @escaping PaginatedBaseCompletion) { 34 | super.init(request: request, client: client, completion: completion) 35 | } 36 | 37 | ///Mark this as private so that we force users to use a library request 38 | private override init(request: KitsuPagedRequest, client: NetworkClientProtocol, completion: @escaping PaginatedBaseCompletion) { 39 | super.init(request: request, client: client, completion: completion) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/Preloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preloader.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 23/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class Preloader { 12 | 13 | public init () {} 14 | 15 | /// Prelad any necessary data 16 | public func preloadData() { 17 | 18 | //The genres need to be preloaded 19 | preloadGenres() 20 | } 21 | 22 | func preloadGenres() { 23 | let genreRequest = KitsuPagedRequest(relativeURL: Constants.Endpoints.genres) 24 | genreRequest.page(limit: 9999) 25 | 26 | let operation = NetworkOperation(request: genreRequest.build(), client: Ookami.shared.networkClient) { json, error in 27 | guard error == nil, 28 | let json = json else { 29 | print("Failed to preload genres") 30 | return 31 | } 32 | 33 | Parser().parse(json: json) { objects in 34 | Database().addOrUpdate(objects) 35 | print("Preloaded genres") 36 | } 37 | 38 | } 39 | 40 | Ookami.shared.queue.addOperation(operation) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Kitsu/Requests/KitsuPagedRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KitsuPagedRequest.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 28/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | public class KitsuPagedRequest: KitsuRequest { 13 | 14 | public struct Page { 15 | var offset: Int = 0 16 | var limit: Int = 100 17 | } 18 | 19 | //The current page 20 | public internal(set) var page: Page = Page() 21 | 22 | override func parameters() -> Parameters { 23 | var params = super.parameters() 24 | params["page"] = ["offset": page.offset, "limit": page.limit] 25 | return params 26 | } 27 | 28 | /// Set the page offset 29 | public func page(offset: Int) { 30 | page.offset = max(0, offset) 31 | } 32 | 33 | /// Set the limit of objects per page 34 | public func page(limit: Int) { 35 | page.limit = max(0, limit) 36 | } 37 | 38 | /// Create a copy of the request 39 | /// 40 | /// - Returns: The copied request 41 | override public func copy() -> KitsuRequest { 42 | let request = KitsuPagedRequest(relativeURL: url, headers: headers, needsAuth: needsAuth) 43 | request.includes = includes 44 | request.filters = filters 45 | request.sort = sort 46 | request.page = page 47 | return request 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Networking/NetworkRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkRequest.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | public struct NetworkRequest: NetworkRequestProtocol { 13 | public var method: HTTPMethod 14 | public var parameters: Parameters? 15 | public var headers: HTTPHeaders? 16 | public var needsAuth: Bool 17 | public var url: String 18 | public internal(set) var urlType: NetworkRequestURLType 19 | 20 | /// Create a network request 21 | /// 22 | /// - Parameters: 23 | /// - relativeURL: The relative url of the request. E.g /anime/1, /users/1 24 | /// - method: The HTTP method 25 | /// - parameters: The parameters of the request 26 | /// - headers: The headers to use for the request 27 | /// - needsAuth: Whether this request needs a user to be authenticated 28 | public init(relativeURL: String, method: HTTPMethod, parameters: Parameters? = nil, headers: HTTPHeaders? = nil, needsAuth: Bool = false) { 29 | self.url = relativeURL 30 | self.urlType = .relative 31 | self.method = method 32 | self.parameters = parameters 33 | self.headers = headers 34 | self.needsAuth = needsAuth 35 | } 36 | 37 | /// Create a network request 38 | /// 39 | /// - Parameters: 40 | /// - absoluteURL: The absolute url of the request. E.g https://kitsu.io/anime/1 41 | /// - method: The HTTP method 42 | /// - parameters: The parameters of the request 43 | /// - headers: The headers to use for the request 44 | /// - needsAuth: Whether this request needs a user to be authenticated 45 | public init(absoluteURL: String, method: HTTPMethod, parameters: Parameters? = nil, headers: HTTPHeaders? = nil, needsAuth: Bool = false) { 46 | self.url = absoluteURL 47 | self.urlType = .absolute 48 | self.method = method 49 | self.parameters = parameters 50 | self.headers = headers 51 | self.needsAuth = needsAuth 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Ookami.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ookami.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 23/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Heimdallr 11 | import Keys 12 | 13 | /// The main class which holds the top level objects 14 | public class Ookami { 15 | 16 | /// Shared instance to use 17 | public static let shared: Ookami = { 18 | return Ookami() 19 | }() 20 | 21 | //The client credentials 22 | public var credentials: OAuthClientCredentials = { 23 | let keys = OokamiKeys() 24 | return OAuthClientCredentials(id: keys.kitsuClientKey, secret: keys.kitsuClientSecret) 25 | }() 26 | 27 | //Heimdallr client 28 | public lazy var heimdallr: Heimdallr = { 29 | let store = OokamiTokenStore() 30 | 31 | let tokenURL = URL(string: Constants.URL.authToken)! 32 | let heim = Heimdallr(tokenURL: tokenURL, credentials: self.credentials, accessTokenStore: store) 33 | return heim 34 | }() 35 | 36 | //Networking client 37 | public lazy var networkClient: NetworkClient = { 38 | let client = NetworkClient(baseURL: Constants.URL.api, heimdallr: self.heimdallr) 39 | return client 40 | }() 41 | 42 | //The main operation queue of the application 43 | public lazy var queue: OperationQueue = { 44 | let q = OperationQueue() 45 | q.maxConcurrentOperationCount = 5 46 | return q 47 | }() 48 | 49 | } 50 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Operations/AsynchronousOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsynchronousOperation.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class AsynchronousOperation : Operation { 12 | 13 | override public var isAsynchronous: Bool { return true } 14 | 15 | private let stateLock = NSLock() 16 | 17 | private var _executing: Bool = false 18 | override public private(set) var isExecuting: Bool { 19 | get { 20 | return stateLock.withCriticalScope { _executing } 21 | } 22 | set { 23 | willChangeValue(forKey: "isExecuting") 24 | stateLock.withCriticalScope { _executing = newValue } 25 | didChangeValue(forKey: "isExecuting") 26 | } 27 | } 28 | 29 | private var _finished: Bool = false 30 | override public private(set) var isFinished: Bool { 31 | get { 32 | return stateLock.withCriticalScope { _finished } 33 | } 34 | set { 35 | willChangeValue(forKey: "isFinished") 36 | stateLock.withCriticalScope { _finished = newValue } 37 | didChangeValue(forKey: "isFinished") 38 | } 39 | } 40 | 41 | /// Complete the operation 42 | /// 43 | /// This will result in the appropriate KVN of isFinished and isExecuting 44 | 45 | public func completeOperation() { 46 | if isExecuting { 47 | isExecuting = false 48 | } 49 | 50 | if !isFinished { 51 | isFinished = true 52 | } 53 | } 54 | 55 | override public func start() { 56 | if isCancelled { 57 | isFinished = true 58 | return 59 | } 60 | 61 | isExecuting = true 62 | 63 | main() 64 | } 65 | 66 | override public func main() { 67 | fatalError("subclasses must override `main`") 68 | } 69 | } 70 | 71 | extension NSLock { 72 | 73 | /// Perform closure within lock. 74 | /// 75 | /// An extension to `NSLock` to simplify executing critical code. 76 | /// 77 | /// - parameter block: The closure to be performed. 78 | 79 | func withCriticalScope( block: () -> T) -> T { 80 | lock() 81 | let value = block() 82 | unlock() 83 | return value 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Operations/NetworkOperation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkOperation.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | /// A Operation for executing a request on a client 13 | public class NetworkOperation: AsynchronousOperation { 14 | 15 | public let request: NetworkRequestProtocol 16 | public let client: NetworkClientProtocol 17 | public let networkCompletion: (JSON?, Error?) -> Void 18 | 19 | /// Create a network operation. 20 | /// 21 | /// - Parameters: 22 | /// - request: The network request 23 | /// - client: The network client 24 | /// - completion: The completion block. Passes an optional JSON object or an optional Error. 25 | public init(request: NetworkRequestProtocol, client: NetworkClientProtocol, completion: @escaping (JSON?, Error?) -> Void) { 26 | self.request = request 27 | self.client = client 28 | self.networkCompletion = completion 29 | } 30 | 31 | override public func main() { 32 | client.execute(request: request) { data, error in 33 | if !self.isCancelled { 34 | self.networkCompletion(data, error) 35 | } 36 | self.completeOperation() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/Cacheable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cacheable.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 27/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | ///Protocol for object cache 13 | protocol Cacheable { 14 | 15 | ///The last time object was updated 16 | var localLastUpdate: Date? { get set } 17 | 18 | ///Function to check whether we can clear the object from cache 19 | ///The default is `true` 20 | func canClearFromCache() -> Bool 21 | 22 | ///Function which gets called before object is cleared from cache 23 | ///This is an optional function 24 | func willClearFromCache() 25 | } 26 | 27 | extension Cacheable { 28 | 29 | /// Check whether the object can be cleared from cache 30 | /// 31 | /// - Returns: Whether object can be cleared from cache 32 | func canClearFromCache() -> Bool { 33 | return true 34 | } 35 | 36 | ///Function which gets called before object is cleared from cache 37 | func willClearFromCache() {} 38 | } 39 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/GettableObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GettableObject.swift 3 | // Ookami 4 | // 5 | // Don't mind the name, i am very bad at naming things :( - Mikunj 6 | // 7 | // Created by Maka on 5/11/16. 8 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 9 | // 10 | 11 | import Foundation 12 | import RealmSwift 13 | 14 | //MAYBE: This functionality can be moved to the Database class 15 | 16 | /// A protocol for defining if a object is gettable 17 | public protocol GettableObject { 18 | //The type of the ID/primary key. E.g: Int, String 19 | associatedtype IDType 20 | 21 | //The type of the return object when fetching multiple 22 | associatedtype MultipleReturnType 23 | 24 | //The object type 25 | associatedtype T 26 | 27 | /// Get an object `T` with an id of type `IDType` 28 | /// 29 | /// - Parameter id: The id 30 | /// - Returns: An object of type `T` or nil if failed to get 31 | static func get(withId id: IDType) -> T? 32 | 33 | 34 | /// Get objects of type `T` with id of type `IDType` 35 | /// 36 | /// - Parameter ids: An array of ids 37 | /// - Returns: A value of type `MultipleReturnType` which contains objects of type `T` 38 | static func get(withIds ids: [IDType]) -> MultipleReturnType 39 | 40 | /// Get all objects of type T 41 | /// 42 | /// - Returns: A value of type `MultipleReturnType` which contains objects of type `T` 43 | static func all() -> MultipleReturnType 44 | } 45 | 46 | /// Apply the extenstion to realm objects 47 | /// This uses `Int` as the default IDType and `Results` for the MultipleReturnType 48 | extension GettableObject where T: Object { 49 | 50 | /// Get a realm object with a given int id 51 | /// 52 | /// - Parameter id: The object id 53 | /// - Returns: A realm object for given id 54 | public static func get(withId id: Int) -> T? { 55 | let r = Database().realm 56 | return r.object(ofType: T.self, forPrimaryKey: id) 57 | } 58 | 59 | /// Get realm objects from an array of given int ids 60 | /// If an object doesn't have a primary key then it will assume 'id' is the value for the primary key 61 | /// 62 | /// - Parameter ids: An array of genre ids 63 | /// - Returns: A Realm result of the realm objects 64 | public static func get(withIds ids: [Int]) -> Results { 65 | let key = T.primaryKey() ?? "id" 66 | let r = Database().realm 67 | return r.objects(T.self).filter("\(key) IN %@", ids) 68 | } 69 | 70 | /// Get all realm objects 71 | /// 72 | /// - Returns: A Realm result of all realm objects 73 | public static func all() -> Results { 74 | let r = Database().realm 75 | return r.objects(T.self) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/JSONParsable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSONParsable.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 5/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | /// Protocol for an object that can be parsable with JSON data 13 | public protocol JSONParsable { 14 | associatedtype T 15 | 16 | ///The type string of the JSON parsable. E.g "anime", "genres" 17 | static var typeString: String { get } 18 | 19 | /// Construct an object from JSON Data 20 | /// 21 | /// - Parameter json: The JSON data 22 | /// - Returns: The parsed object 23 | static func parse(json: JSON) -> T? 24 | } 25 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/NetworkClientProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkClientProtocol.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | 12 | public protocol NetworkClientProtocol { 13 | 14 | /// The base url of the api 15 | var baseURL: String { get } 16 | 17 | /// Execute a network request 18 | /// 19 | /// - Parameters: 20 | /// - request: The network request 21 | /// - completion: The callback closure. Passes JSON data and error. 22 | func execute(request: NetworkRequestProtocol, completion: @escaping (JSON?, Error?) -> Void) 23 | } 24 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/NetworkRequestProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkRequestProtocol.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 6/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | //The type of url specified in the request 13 | public enum NetworkRequestURLType { 14 | case relative 15 | case absolute 16 | } 17 | 18 | public protocol NetworkRequestProtocol { 19 | var method: HTTPMethod { get } 20 | var parameters: Parameters? { get } 21 | var headers: HTTPHeaders? { get } 22 | var needsAuth: Bool { get } 23 | var url: String { get } 24 | var urlType: NetworkRequestURLType { get } 25 | } 26 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Protocols/RealmStorable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmStorable.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 12/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | //Protocol for defining if an object can be stored in realm 13 | public protocol RealmStorable { 14 | 15 | /// Whether the object can be stored in realm 16 | /// 17 | /// - Returns: A Boolean dictating whether object can be stored or not 18 | func canBeStored() -> Bool 19 | } 20 | 21 | //By default all realm object can be stored. 22 | //This can however be changed by object-to-object basis 23 | extension Object: RealmStorable { 24 | 25 | public func canBeStored() -> Bool { 26 | return true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Utility/OokamiTokenStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OokamiTokenStore.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 23/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Heimdallr 11 | 12 | /// A custom token store class for Heimdallr as the default KeychainStore implementation was not working 13 | // https://github.com/trivago/Heimdallr.swift/issues/98 14 | public class OokamiTokenStore: OAuthAccessTokenStore { 15 | 16 | let defaults = UserDefaults.standard 17 | 18 | //Keys constants 19 | struct Keys { 20 | private init() {} 21 | static let accessToken = "access_token" 22 | static let tokenType = "token_type" 23 | static let expiresAt = "expires_at" 24 | static let refreshToken = "refresh_token" 25 | } 26 | 27 | public init() { 28 | } 29 | 30 | public func clearStoredToken() { 31 | defaults.removeObject(forKey: Keys.accessToken) 32 | defaults.removeObject(forKey: Keys.tokenType) 33 | defaults.removeObject(forKey: Keys.expiresAt) 34 | defaults.removeObject(forKey: Keys.refreshToken) 35 | } 36 | 37 | public func storeAccessToken(_ accessToken: OAuthAccessToken?) { 38 | guard let accessToken = accessToken else { 39 | clearStoredToken() 40 | return 41 | } 42 | 43 | defaults.set(accessToken.accessToken, forKey: Keys.accessToken) 44 | defaults.set(accessToken.tokenType, forKey: Keys.tokenType) 45 | defaults.set(accessToken.expiresAt?.timeIntervalSince1970.description, forKey: Keys.expiresAt) 46 | defaults.set(accessToken.refreshToken, forKey: Keys.refreshToken) 47 | } 48 | 49 | public func retrieveAccessToken() -> OAuthAccessToken? { 50 | let accessToken = defaults.value(forKey: Keys.accessToken) as! String? 51 | let tokenType = defaults.value(forKey: Keys.tokenType) as! String? 52 | let refreshToken = defaults.value(forKey: Keys.refreshToken) as! String? 53 | let e = defaults.value(forKey: Keys.expiresAt) as! String? 54 | let expiresAt = e.flatMap { description in 55 | Double(description).flatMap { timeInterval in 56 | return Date(timeIntervalSince1970: timeInterval) 57 | } 58 | } 59 | 60 | if let accessToken = accessToken, let tokenType = tokenType { 61 | return OAuthAccessToken(accessToken: accessToken, tokenType: tokenType, expiresAt: expiresAt, refreshToken: refreshToken) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Utility/RealmProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmProvider().swift 3 | // Ookami 4 | // 5 | // Created by Maka on 5/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | public class RealmProvider { 13 | 14 | public init() {} 15 | 16 | /// Get a realm instance. This works the same way as calling try! Realm() 17 | /// Therefore, you should follow it's guidelines such as not using an instance across threads. Instead call this again on the new thread to recieve another instance. 18 | /// 19 | /// This is a utility function which helps with testing. If a class of 'XCTest' is detected then an in-memory realm is made and returned. Else it just returns a normal instance. 20 | /// 21 | /// - Returns: A normal realm instance or an in-memory instance if being called within an XCTest class 22 | public func realm() -> Realm { 23 | if let _ = NSClassFromString("XCTest") { 24 | return try! Realm(configuration: Realm.Configuration(inMemoryIdentifier: "realm-test-id")) 25 | } else { 26 | return try! Realm(); 27 | 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /OokamiKit/Classes/Utility/UserHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserHelper.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 28/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | ///A bunch of helper functions for user 12 | public class UserHelper { 13 | 14 | static var currentUser: CurrentUser = CurrentUser() 15 | static var database: Database = Database() 16 | 17 | private init() {} 18 | 19 | /// Delete entries from the given users library, if the id is not in the array. 20 | /// 21 | /// - Parameters: 22 | /// - array: The array of entry ids that should not be deleted. 23 | /// - type: The type of entries to check. 24 | /// - id: The user id to check library of. 25 | static func deleteEntries(notIn array: [Int], type: Media.MediaType, forUser id: Int) { 26 | let entries = LibraryEntry.belongsTo(user: id, type: type).filter("NOT id in %@", array) 27 | 28 | database.delete(entries) 29 | } 30 | 31 | /// Check whether the current user has the `media` with `id` in their library 32 | /// 33 | /// - Parameters: 34 | /// - media: The media type 35 | /// - id: The id of the media 36 | /// - Returns: Whether the media is present in the user's library 37 | static func currentUserHas(media: Media.MediaType, id: Int) -> Bool { 38 | return entry(forMedia: media, id: id) != nil 39 | } 40 | 41 | /// Get the entry with a given `type` and `id` that belongs to the current user 42 | /// 43 | /// - Parameters: 44 | /// - type: The media type 45 | /// - id: The id of the media 46 | /// - Returns: The library entry if current user has it, or nil if no entry is found with the given media type and id 47 | public static func entry(forMedia type: Media.MediaType, id: Int) -> LibraryEntry? { 48 | guard let currentID = currentUser.userID else { 49 | return nil 50 | } 51 | 52 | let entries = LibraryEntry.belongsTo(user: currentID, type: type).filter("media.id = %d", id) 53 | return entries.first 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /OokamiKit/Extensions/Date+Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Utils.swift 3 | // Ookami 4 | // 5 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension Date { 11 | 12 | //An iso-8601 formatter 13 | public static let iso8601Formatter: DateFormatter = { 14 | let formatter = DateFormatter() 15 | formatter.calendar = Calendar(identifier: .iso8601) 16 | formatter.locale = Locale(identifier: "en_US_POSIX") 17 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 18 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 19 | return formatter 20 | }() 21 | 22 | /// The ISO8601 string repreentation for the date 23 | var iso8601: String { 24 | return Date.iso8601Formatter.string(from: self) 25 | } 26 | 27 | /// Convert a String to a Date 28 | /// 29 | /// - Parameter dateString: The String 30 | /// - Returns: A Date if string could be converted else nil 31 | static func from(string dateString: String) -> Date? { 32 | 33 | if dateString.isEmpty { return nil } 34 | 35 | let dateFormatter = DateFormatter() 36 | 37 | switch dateString.count { 38 | case 10: 39 | dateFormatter.dateFormat = "YYYY-MM-dd" 40 | 41 | case 19: 42 | dateFormatter.dateFormat = "YYYY-MM-dd'T'HH:mm:ss" 43 | 44 | case 21: 45 | dateFormatter.dateFormat = "YYYY-MM-dd'T'HH:mm:ss.S" 46 | 47 | case 22: 48 | dateFormatter.dateFormat = "YYYY-MM-dd'T'HH:mm:ss.SS" 49 | 50 | case 24: 51 | dateFormatter.dateFormat = "YYYY-MM-dd'T'HH:mm:ss.SSSZ" 52 | 53 | default: 54 | break 55 | } 56 | 57 | return dateFormatter.date(from: dateString) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /OokamiKit/Models/Genre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Genre.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 5/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import SwiftyJSON 12 | 13 | public class Genre: Object, Cacheable { 14 | 15 | public dynamic var id = -1 16 | public dynamic var slug = "" 17 | public dynamic var name = "" 18 | public dynamic var genreDescription = "" 19 | 20 | override public static func primaryKey() -> String { 21 | return "id" 22 | } 23 | 24 | override public static func ignoredProperties() -> [String] { 25 | return [] 26 | } 27 | 28 | /// MARK:- Cacheable 29 | public dynamic var localLastUpdate: Date? 30 | } 31 | 32 | extension Genre: GettableObject { 33 | 34 | public typealias T = Genre 35 | 36 | /// Get a genre with the given name. 37 | /// 38 | /// - Parameter name: The name of the genre. 39 | /// - Returns: The genre that has the given name. 40 | public static func get(withName name: String) -> Genre? { 41 | return Genre.all().filter("name ==[c] %@", name).first 42 | } 43 | } 44 | 45 | extension Genre: JSONParsable { 46 | 47 | public static var typeString: String { return "genres" } 48 | 49 | /// Construct an `Genre` object from JSON Data 50 | /// 51 | /// - Parameter json: The JSON Data 52 | /// - Returns: A genre if JSON data was valid 53 | public static func parse(json: JSON) -> Genre? { 54 | guard json["type"].stringValue == Genre.typeString else { 55 | return nil 56 | } 57 | 58 | let attributes = json["attributes"] 59 | let genre = Genre() 60 | genre.id = json["id"].intValue 61 | genre.slug = attributes["slug"].stringValue 62 | genre.name = attributes["name"].stringValue 63 | genre.genreDescription = attributes["description"].stringValue 64 | 65 | return genre 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /OokamiKit/Models/LastFetched.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LastFetched.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 30/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | //An object to track when a library was last fetched 13 | public class LastFetched: Object { 14 | 15 | //The user to track the last library fetch for 16 | public dynamic var userID: Int = -1 17 | 18 | //The last fetch of the anime library 19 | public dynamic var anime: Date = Date(timeIntervalSince1970: 0) 20 | 21 | //The last fetch of the manga library 22 | public dynamic var manga: Date = Date(timeIntervalSince1970: 0) 23 | 24 | override public static func primaryKey() -> String { 25 | return "userID" 26 | } 27 | 28 | 29 | 30 | } 31 | 32 | extension LastFetched: GettableObject { public typealias T = LastFetched } 33 | -------------------------------------------------------------------------------- /OokamiKit/Models/Media+Genre.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Media+Genre.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 20/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import SwiftyJSON 12 | 13 | //A model to link Media and Genre together 14 | public class MediaGenre: Object { 15 | //The media this genre belongs to 16 | public internal(set) dynamic var mediaID = -1 { 17 | didSet { compoundKey = self.compoundKeyValue() } 18 | } 19 | 20 | //The type of media this belongs to 21 | public internal(set) dynamic var mediaType = "" { 22 | didSet { compoundKey = self.compoundKeyValue() } 23 | } 24 | 25 | //The genre id that this links to 26 | public internal(set) dynamic var genreID = -1 { 27 | didSet { compoundKey = self.compoundKeyValue() } 28 | } 29 | 30 | dynamic var compoundKey: String = "0-" 31 | func compoundKeyValue() -> String { 32 | return "\(mediaID)-\(mediaType)-\(genreID)" 33 | } 34 | 35 | override public static func primaryKey() -> String { 36 | return "compoundKey" 37 | } 38 | 39 | /// The genre object that the MediaGenre is linked to 40 | public var genre: Genre? { 41 | return Genre.get(withId: genreID) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /OokamiKit/Models/MediaTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaTitle.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 30/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | /** 13 | The reason we can safely assign compoundKey via didSet (note realm will not call didSet or willSet after object has been written to database) is because the mediaId and the key of the title will not change after it has first been added. 14 | 15 | WARNING: If you do decide to change the mediaID or key in OokamiKit then the compoundKey will be invalid! 16 | The key and value can only be modified internally (in OokamiKit) thus preventing the problem where apps using this framework modify the values accidentally 17 | */ 18 | public class MediaTitle: Object { 19 | 20 | public enum LanguageKey: String { 21 | case english = "en" 22 | case japanese = "ja_jp" 23 | case romanized = "en_jp" 24 | 25 | /// Get the string representation of the language key 26 | /// 27 | /// - Returns: The string value 28 | public func toString() -> String { 29 | switch self { 30 | case .english: 31 | return "English" 32 | case .japanese: 33 | return "Japanese" 34 | case .romanized: 35 | return "Romanized" 36 | } 37 | } 38 | } 39 | 40 | //The media this title belongs to 41 | public internal(set) dynamic var mediaID = -1 { 42 | didSet { compoundKey = self.compoundKeyValue() } 43 | } 44 | 45 | //The type of media this belongs to 46 | public internal(set) dynamic var mediaType = "" { 47 | didSet { compoundKey = self.compoundKeyValue() } 48 | } 49 | 50 | //the language key, E.g en or en_jp 51 | public internal(set) dynamic var key = "" { 52 | didSet { compoundKey = self.compoundKeyValue() } 53 | } 54 | 55 | //The language key in an enum format 56 | public var languageKey: LanguageKey? { 57 | return LanguageKey(rawValue: key) 58 | } 59 | 60 | //The title for the given key 61 | public internal(set) dynamic var value = "" 62 | 63 | dynamic var compoundKey: String = "0-" 64 | func compoundKeyValue() -> String { 65 | return "\(mediaID)-\(mediaType)-\(key)" 66 | } 67 | 68 | override public static func primaryKey() -> String { 69 | return "compoundKey" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /OokamiKit/Models/README: -------------------------------------------------------------------------------- 1 | Reason for compound keys on some objects: 2 | 3 | In Realm, if we have a object which contains a list of items, then when you create the object again (which is what we're doing with parse function) and you call realm.add(object, update: true) then you will have 2x the amount of items causing redundancy. 4 | 5 | Example: 6 | You have a `User`(a realm object with a primary key 'id') with 3 names. You store this by having a property: let names = List, where UserName is a realm object with just a value field and no primary key. 7 | When you first add the object you create 3 UserName objects with values "A", "B", "C" and append them to the names list. After saving these UserName objects will be in the database (3 of them to be exact). 8 | 9 | However, say we create a new `User` object with the same primary key as before. This is a brand new object and thus its names list count is 0. We add the names "D", "E", "F" to this and save it using realm.add(user, update: true). Now if you get the user from realm and inspect the names property you will see that it has "D", "E" and "F" as expected. If however you get every `UserName` object in the database, you notice that you have 6!. "A", "B", "C", "D", "E", and "F". The first 3 names become redundant and if this continues, with each new user being created and updated (which is what the parse function is doing) you produce 3 redundant objects and over time these clutter space. 10 | 11 | To avoid this, we can simple add a compound key with (userID, name) to the `UserName` object and assign it as the primary key so that when we do update the `User` it will not create duplicates of the same object. 12 | 13 | However make a note of how you implement the compound keys. If you implement the compound keys with the willSet and didSet property listerners then be warned that realm will not call these after the object has been added to the database. 14 | 15 | If you know for certain that these values will not change throughout the life of the application then you may use it (The MediaTitle object does this as the title will not be modified at all by the application, only modified by incoming data from server). Otherwise refer to: https://github.com/realm/realm-cocoa/issues/1192#issuecomment-228455097 to see how you can implement it. 16 | -------------------------------------------------------------------------------- /OokamiKit/OokamiKit.h: -------------------------------------------------------------------------------- 1 | // 2 | // OokamiKit.h 3 | // OokamiKit 4 | // 5 | // Created by Maka on 4/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for OokamiKit. 12 | FOUNDATION_EXPORT double OokamiKitVersionNumber; 13 | 14 | //! Project version string for OokamiKit. 15 | FOUNDATION_EXPORT const unsigned char OokamiKitVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /OokamiKit/Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSAppTransportSecurity 22 | 23 | NSAllowsArbitraryLoads 24 | 25 | 26 | NSPrincipalClass 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /OokamiKitTests/Data/entry-anime-jigglyslime.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "340253", 3 | "type": "libraryEntries", 4 | "links": { 5 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253" 6 | }, 7 | "attributes": { 8 | "status": "current", 9 | "progress": 131, 10 | "reconsuming": true, 11 | "reconsumeCount": 0, 12 | "notes": null, 13 | "private": true, 14 | "rating": "5.0", 15 | "ratingTwenty": "20", 16 | "updatedAt": "2016-08-15T11:01:29.181Z", 17 | "progressedAt": "2017-06-24T14:59:22.951Z", 18 | "startedAt": "2017-06-23T07:22:43.932Z", 19 | "finishedAt": "2017-06-24T14:59:22.951Z", 20 | }, 21 | "relationships": { 22 | "user": { 23 | "links": { 24 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/relationships/user", 25 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/user" 26 | }, 27 | "data": { 28 | "type": "users", 29 | "id": "2875" 30 | } 31 | }, 32 | "anime": { 33 | "links": { 34 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/relationships/media", 35 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/media" 36 | }, 37 | "data": { 38 | "type": "anime", 39 | "id": "6448" 40 | } 41 | }, 42 | "unit": { 43 | "links": { 44 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/relationships/unit", 45 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/library-entries/340253/unit" 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /OokamiKitTests/Data/genre-adventure.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2", 3 | "type": "genres", 4 | "links": { 5 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/genres/2" 6 | }, 7 | "attributes": { 8 | "name": "Adventure", 9 | "slug": "adventure", 10 | "description": null 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /OokamiKitTests/Data/user-jigglyslime.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2875", 3 | "type": "users", 4 | "links": { 5 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875" 6 | }, 7 | "attributes": { 8 | "name": "Jigglyslime", 9 | "pastNames": ["Ninja", "Pirate"], 10 | "about": "", 11 | "bio": "( ͡° ͜ʖ ͡°) Eᴠᴇʀʏ 60 sᴇᴄᴏɴᴅs ɪɴ Aғʀɪᴄᴀ, ᴀ ᴍɪɴᴜᴛᴇ ᴘᴀssᴇs. Tᴏɢᴇᴛʜᴇʀ ᴡᴇ ᴄᴀɴ sᴛᴏᴘ ᴛʜɪs. Pʟᴇᴀsᴇ sᴘʀᴇᴀᴅ ᴛʜᴇ ᴡᴏʀᴅ ( ͡° ͜ʖ ͡°)", 12 | "aboutFormatted": null, 13 | "location": "", 14 | "website": null, 15 | "waifuOrHusbando": "Waifu", 16 | "toFollow": false, 17 | "followersCount": 55, 18 | "createdAt": "2013-05-17T06:10:09.481Z", 19 | "followingCount": 13, 20 | "onboarded": false, 21 | "lifeSpentOnAnime": 129205, 22 | "birthday": null, 23 | "gender": null, 24 | "facebookId": null, 25 | "updatedAt": "2016-10-31T02:11:12.727Z", 26 | "avatar": { 27 | "original": "https://static.hummingbird.me/users/avatars/000/002/875/original/114367.jpg?1392948358" 28 | }, 29 | "coverImage": { 30 | "original": "https://static.hummingbird.me/users/cover_images/000/002/875/original/2875-rainbowish_fb_cover_by_n3x0n-d5fqohg.png?1389986097" 31 | }, 32 | "email": "jigglyslime@kitsu.io", 33 | "password": null, 34 | "ratingSystem": "simple" 35 | }, 36 | "relationships": { 37 | "waifu": { 38 | "links": { 39 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/relationships/waifu", 40 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/waifu" 41 | } 42 | }, 43 | "followers": { 44 | "links": { 45 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/relationships/followers", 46 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/followers" 47 | } 48 | }, 49 | "following": { 50 | "links": { 51 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/relationships/following", 52 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/following" 53 | } 54 | }, 55 | "blocks": { 56 | "links": { 57 | "self": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/relationships/blocks", 58 | "related": "https://kitsu-api-staging.herokuapp.com/api/edge/users/2875/blocks" 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /OokamiKitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/AnimeFilterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnimeFilterSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | 13 | class AnimeFilterSpec: QuickSpec { 14 | 15 | override func spec() { 16 | describe("Anime Filter") { 17 | context("Episode filter") { 18 | it("should cap the episodes properley") { 19 | let f = AnimeFilter() 20 | f.episodes = RangeFilter(start: -1, end: 999999) 21 | expect(f.episodes.start).to(equal(1)) 22 | expect(f.episodes.end).to(equal(99999)) 23 | 24 | f.episodes = RangeFilter(start: 2, end: 10) 25 | expect(f.episodes.start).to(equal(2)) 26 | expect(f.episodes.end).to(equal(10)) 27 | } 28 | } 29 | 30 | context("Constructing") { 31 | it("should correctly construct a dictionary") { 32 | let f = AnimeFilter() 33 | f.episodes = RangeFilter(start: 1, end: nil) 34 | 35 | let defaultDict = f.construct() 36 | expect(defaultDict.keys).toNot(contain("episodeCount", "ageRating", "streamers", "season", "subtype")) 37 | 38 | f.ageRatings = [.g, .r18] 39 | f.streamers = [.netflix, .hulu] 40 | f.seasons = [.spring, .summer] 41 | f.episodes = RangeFilter(start: 1, end: 10) 42 | f.subtypes = [.tv, .movie] 43 | 44 | let dict = f.construct() 45 | expect(dict["episodeCount"] as? String).to(equal("..10")) 46 | expect(dict["ageRating"] as? String).to(equal("G,R18")) 47 | expect(dict["streamers"] as? String).to(equal("Netflix,Hulu")) 48 | expect(dict["season"] as? String).to(equal("spring,summer")) 49 | expect(dict["subtype"] as? String).to(equal("TV,movie")) 50 | 51 | f.episodes = RangeFilter(start: 2, end: 10) 52 | expect(f.construct()["episodeCount"] as? String).to(equal("2..10")) 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/CurrentUserSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrentUserSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 1/1/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | @testable import Heimdallr 13 | import Result 14 | import RealmSwift 15 | 16 | private class StubClearHeim: Heimdallr { 17 | public var token = false 18 | 19 | override public var hasAccessToken: Bool { 20 | return token 21 | } 22 | 23 | init(stubError: NSError? = nil) { 24 | super.init(tokenURL: URL(string: "http://kitsu.io")!) 25 | } 26 | 27 | override func clearAccessToken() { 28 | token = false 29 | } 30 | } 31 | 32 | class CurrentUserSpec: QuickSpec { 33 | override func spec() { 34 | describe("Current User") { 35 | 36 | let heim = StubClearHeim() 37 | let currentUser = CurrentUser(heimdallr: heim, userIDKey: "auth-spec-user") 38 | 39 | context("Current user id") { 40 | it("should correctly store values") { 41 | currentUser.userID = 1 42 | expect(currentUser.userID).to(equal(1)) 43 | 44 | currentUser.userID = nil 45 | expect(currentUser.userID).to(beNil()) 46 | } 47 | } 48 | 49 | context("Logout") { 50 | it("should clear the user id and token") { 51 | 52 | UserDefaults.standard.set(1, forKey: currentUser.userIDKey) 53 | heim.token = true 54 | 55 | expect(currentUser.userID).to(equal(1)) 56 | expect(currentUser.isLoggedIn()).to(beTrue()) 57 | 58 | currentUser.logout() 59 | expect(currentUser.userID).to(beNil()) 60 | expect(currentUser.isLoggedIn()).to(beFalse()) 61 | } 62 | } 63 | 64 | } 65 | 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/KitsuLibraryRequestSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KitsuLibraryRequestSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 28/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | 13 | class KitsuLibraryRequestSpec: QuickSpec { 14 | 15 | override func spec() { 16 | describe("Kitsu Library Request") { 17 | it("should correctly apply filters") { 18 | let date = Date(timeIntervalSince1970: 1) 19 | let type: Media.MediaType = .anime 20 | let status: LibraryEntry.Status = .current 21 | let request = KitsuLibraryRequest(userID: 1, type: type, status: status, since: date) 22 | 23 | let filters = request.filters 24 | let includes = request.includes 25 | 26 | expect(filters["user_id"] as? Int).to(equal(1)) 27 | expect(filters["kind"] as? String).to(equal(type.rawValue)) 28 | expect(includes).to(contain(type.rawValue)) 29 | expect(filters["status"] as? String).to(equal(status.rawValue)) 30 | //expect(filters["since"] as? String).to(equal("1970-01-01T00:00:01.000Z")) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/KitsuPagedRequestSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KitsuPagedRequestSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 28/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | 13 | class KitsuPagedRequestSpec: QuickSpec { 14 | 15 | override func spec() { 16 | describe("Kitsu Paged Request") { 17 | 18 | context("Modifiers") { 19 | it("should correctly modify page limit") { 20 | let request = KitsuPagedRequest(relativeURL: "/test") 21 | request.page(limit: 1) 22 | expect(request.page.limit).to(equal(1)) 23 | 24 | request.page(limit: -1) 25 | expect(request.page.limit).to(equal(0)) 26 | } 27 | 28 | it("should correctly modify page offset") { 29 | let request = KitsuPagedRequest(relativeURL: "/test") 30 | request.page(offset: 1) 31 | expect(request.page.offset).to(equal(1)) 32 | 33 | request.page(offset: -1) 34 | expect(request.page.offset).to(equal(0)) 35 | } 36 | } 37 | 38 | context("Building") { 39 | it("should correctly return the parameters") { 40 | let request = KitsuPagedRequest(relativeURL: "/test") 41 | 42 | request.page(limit: 1) 43 | request.page(offset: 1) 44 | 45 | let params = request.parameters() 46 | let page = params["page"] as! [String: Any] 47 | let offset = page["offset"] as? Int 48 | let limit = page["limit"] as? Int 49 | 50 | expect(offset).to(equal(1)) 51 | expect(limit).to(equal(1)) 52 | } 53 | 54 | 55 | } 56 | 57 | context("Copying") { 58 | it("should make a clean copy") { 59 | let original = KitsuPagedRequest(relativeURL: "/test") 60 | let request = original.copy() as! KitsuPagedRequest 61 | request.filter(key: "abc", value: 1) 62 | request.include("abc") 63 | request.sort(by: "bob") 64 | request.page(limit: 1) 65 | request.page(offset: 1) 66 | 67 | expect(original.filters).to(beEmpty()) 68 | expect(original.includes).to(beEmpty()) 69 | expect(original.sort).to(beNil()) 70 | expect(original.page.offset).toNot(equal(request.page.offset)) 71 | expect(original.page.limit).toNot(equal(request.page.limit)) 72 | } 73 | } 74 | } 75 | } 76 | 77 | } 78 | 79 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/MangaFilterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaFilterSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 22/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | 13 | class MangaFilterSpec: QuickSpec { 14 | 15 | override func spec() { 16 | describe("Manga Filter") { 17 | 18 | context("Constructing") { 19 | it("should correctly construct a dictionary") { 20 | let f = MangaFilter() 21 | 22 | let defaultDict = f.construct() 23 | expect(defaultDict.keys).toNot(contain("subtype")) 24 | 25 | f.subtypes = [.manga, .manhua] 26 | 27 | let dict = f.construct() 28 | expect(dict["subtype"] as? String).to(contain("manga,manhua")) 29 | } 30 | } 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/RangeFilterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RangeFilterSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 7/2/17. 6 | // Copyright © 2017 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | 13 | class RangeFilterSpec: QuickSpec { 14 | 15 | override func spec() { 16 | describe("Range Filter") { 17 | context("Value correction") { 18 | it("should always make start < end") { 19 | var f = RangeFilter(start: 9, end: 2) 20 | f.applyCorrection() 21 | expect(f.start).to(equal(2)) 22 | expect(f.end).to(equal(9)) 23 | 24 | var withoutEnd = RangeFilter(start: 9, end: nil) 25 | withoutEnd.applyCorrection() 26 | expect(withoutEnd.start).to(equal(9)) 27 | expect(withoutEnd.end).to(beNil()) 28 | } 29 | } 30 | 31 | context("Value capping") { 32 | it("should cap the start and end correctly") { 33 | var f1 = RangeFilter(start: 1, end: 5) 34 | f1.capValues(min: 2, max: 4) 35 | expect(f1.start).to(equal(2)) 36 | expect(f1.end).to(equal(4)) 37 | 38 | var f2 = RangeFilter(start: 2, end: 4) 39 | f2.capValues(min: 1, max: 5) 40 | expect(f2.start).to(equal(2)) 41 | expect(f2.end).to(equal(4)) 42 | 43 | var f3 = RangeFilter(start: 1, end: nil) 44 | f3.capValues(min: 2, max: 4) 45 | expect(f3.start).to(equal(2)) 46 | expect(f3.end).to(beNil()) 47 | } 48 | } 49 | 50 | context("Description") { 51 | it("should append the end value if it's set") { 52 | let f = RangeFilter(start: 10, end: 11) 53 | expect(f.description).to(equal("10..11")) 54 | } 55 | 56 | it("should not append the end value if it's nil") { 57 | let f = RangeFilter(start: 10, end: nil) 58 | expect(f.description).to(equal("10..")) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Classes/UserServiceSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserServiceSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 29/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | import RealmSwift 13 | import OHHTTPStubs 14 | 15 | class UserServiceSpec: QuickSpec { 16 | 17 | override func spec() { 18 | describe("User Service") { 19 | 20 | var client: NetworkClient! 21 | 22 | beforeEach { 23 | client = NetworkClient(baseURL: "http://kitsu.io", heimdallr: StubAuthHeimdallr()) 24 | } 25 | 26 | afterEach { 27 | OHHTTPStubs.removeAllStubs() 28 | } 29 | 30 | it("should pass the network error") { 31 | stub(condition: isHost("kitsu.io")) { _ in 32 | return OHHTTPStubsResponse(error: NetworkClientError.error("failed to get page")) 33 | } 34 | 35 | 36 | waitUntil { done in 37 | let request = NetworkRequest(relativeURL: "/user/1", method: .get) 38 | UserService(client: client).get(request: request) { user, error in 39 | expect(error).toNot(beNil()) 40 | expect(user).to(beNil()) 41 | done() 42 | } 43 | } 44 | } 45 | 46 | it("should add the user to the database and pass it back") { 47 | let userJSON = TestHelper.loadJSON(fromFile: "user-jigglyslime")! 48 | 49 | stub(condition: isHost("kitsu.io")) { _ in 50 | let data: [String : Any] = ["data": userJSON.dictionaryObject!] 51 | return OHHTTPStubsResponse(jsonObject: data, statusCode: 200, headers: ["Content-Type": "application/vnd.api+json"]) 52 | } 53 | 54 | 55 | waitUntil { done in 56 | let request = NetworkRequest(relativeURL: "/user/1", method: .get) 57 | UserService(client: client).get(request: request) { user, error in 58 | expect(error).to(beNil()) 59 | expect(user?.name).to(equal("Jigglyslime")) 60 | expect(User.get(withId: 2875)).toNot(beNil()) 61 | done() 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Helper/Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestHelper.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 4/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftyJSON 11 | import RealmSwift 12 | 13 | class TestHelper { 14 | 15 | /// Create a JSON object with data and includes 16 | /// 17 | /// - Parameters: 18 | /// - data: The data field value 19 | /// - includes: The includes field value 20 | static func json(data: Any? = nil, included: Any? = nil) -> JSON { 21 | var dict: [String: Any] = [:] 22 | if let data = data { 23 | dict["data"] = data 24 | } 25 | if let included = included { 26 | dict["included"] = included 27 | } 28 | return JSON(dict) 29 | } 30 | 31 | /// Load a JSON file 32 | /// 33 | /// - Parameter file: The file name without extension 34 | /// - Returns: the JSON content if valid file/contents 35 | static func loadJSON(fromFile file: String) -> JSON? { 36 | if let path = Bundle.main.path(forResource: file, ofType: "json") { 37 | do { 38 | let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .alwaysMapped) 39 | let json = JSON(data: data) 40 | return json 41 | } catch let error { 42 | print(error.localizedDescription) 43 | return nil 44 | } 45 | } else { 46 | print("Invalid filename/path \(file).") 47 | return nil 48 | } 49 | } 50 | 51 | /// Creates objects in the realm database 52 | /// 53 | /// - Parameters: 54 | /// - object: The object class 55 | /// - realm: The realm 56 | /// - amount: Amount of objects to create 57 | /// - objectModifer: A closure for modifying the properties of the object before it is added to the realm. It passes the index and the object as its arguments. 58 | static func create(object: T.Type, inRealm realm: Realm, amount: Int, objectModifer: (Int, T) -> Void) { 59 | try! realm.write { 60 | for i in 0.. Void 17 | 18 | var stubError: NSError? 19 | var authBlock: VoidClosure? 20 | 21 | init(stubError: NSError? = nil, authenticationBlock: VoidClosure? = nil) { 22 | super.init(tokenURL: URL(string: "http://ookami-test.kitsu.io")!) 23 | self.stubError = stubError 24 | self.authBlock = authenticationBlock 25 | } 26 | 27 | override func requestAccessToken(username: String, password: String, completion: @escaping (Result) -> ()) { 28 | completion(.success()) 29 | } 30 | 31 | //A stub authenticate request that return a stub error or the request if stubError is not set 32 | //Also calls the callback block 33 | override func authenticateRequest(_ request: URLRequest, completion: @escaping (Result) -> Void) { 34 | 35 | authBlock?() 36 | 37 | if stubError != nil { 38 | completion(.failure(stubError!)) 39 | } else { 40 | completion(.success(request)) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Helper/StubCacheObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubCacheObject.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 27/12/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | @testable import OokamiKit 12 | 13 | class StubCacheObject: Object, Cacheable { 14 | 15 | dynamic var id = -1 16 | dynamic var localLastUpdate: Date? 17 | dynamic var clearFromCache: Bool = true 18 | 19 | override static func primaryKey() -> String { 20 | return "id" 21 | } 22 | 23 | func canClearFromCache() -> Bool { 24 | return clearFromCache 25 | } 26 | } 27 | 28 | extension StubCacheObject: GettableObject { typealias T = StubCacheObject } 29 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Helper/StubRealmObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StubRealmObject.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 8/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | import OokamiKit 12 | import SwiftyJSON 13 | 14 | class StubRealmObject: Object { 15 | dynamic var id = -1 16 | dynamic var data = "" 17 | 18 | override static func primaryKey() -> String { 19 | return "id" 20 | } 21 | } 22 | 23 | extension StubRealmObject: GettableObject { typealias T = StubRealmObject } 24 | extension StubRealmObject: JSONParsable { 25 | 26 | public static var typeString: String { return "testStub" } 27 | 28 | public static func parse(json: JSON) -> StubRealmObject? { 29 | guard let id = json["id"].int else { 30 | return nil 31 | } 32 | 33 | let o = StubRealmObject() 34 | o.id = id 35 | return o 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Models/GenreSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenreSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 5/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RealmSwift 12 | import SwiftyJSON 13 | @testable import OokamiKit 14 | 15 | 16 | class GenreSpec: QuickSpec { 17 | 18 | override func spec() { 19 | describe("Genre") { 20 | let genreJSON = TestHelper.loadJSON(fromFile: "genre-adventure")! 21 | var testRealm: Realm! 22 | 23 | beforeEach { 24 | testRealm = RealmProvider().realm() 25 | } 26 | 27 | afterEach { 28 | try! testRealm.write { 29 | testRealm.deleteAll() 30 | } 31 | } 32 | 33 | context("Parsing") { 34 | it("should parse a genre JSON correctly") { 35 | let g = Genre.parse(json: genreJSON) 36 | expect(g).toNot(beNil()) 37 | 38 | let genre = g! 39 | expect(genre.id).to(equal(2)) 40 | expect(genre.name).to(equal("Adventure")) 41 | expect(genre.slug).to(equal("adventure")) 42 | expect(genre.genreDescription).to(equal("")) 43 | 44 | } 45 | 46 | it("should return nil on bad JSON") { 47 | let json = JSON("badJSON") 48 | let genre = Genre.parse(json: json) 49 | expect(genre).to(beNil()) 50 | } 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /OokamiKitTests/Specs/Models/RealmGettableObjectSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RealmGettableObjectSpec.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 9/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | @testable import OokamiKit 12 | import RealmSwift 13 | 14 | class RealmGettableObjectSpec: QuickSpec { 15 | 16 | override func spec() { 17 | describe("Realm Gettable objects") { 18 | var realm: Realm! 19 | 20 | beforeEach { 21 | realm = RealmProvider().realm() 22 | } 23 | 24 | afterEach { 25 | try! realm.write { 26 | realm.deleteAll() 27 | } 28 | } 29 | 30 | context("Fetching") { 31 | it("should be able to fetch an object from the database") { 32 | TestHelper.create(object: StubRealmObject.self, inRealm: realm, amount: 1) { index, object in 33 | object.id = 1 34 | } 35 | 36 | let another = StubRealmObject.get(withId: 1) 37 | expect(another).toNot(beNil()) 38 | expect(another!.id).to(equal(1)) 39 | } 40 | 41 | it("should be able to fetch multiple objects from the database") { 42 | var ids: [Int] = [] 43 | TestHelper.create(object: StubRealmObject.self, inRealm: realm, amount: 3) { index, object in 44 | object.id = index 45 | ids.append(index) 46 | } 47 | 48 | let objects = StubRealmObject.get(withIds: ids) 49 | expect(objects).to(haveCount(3)) 50 | } 51 | 52 | it("should be able to fetch all objects from the database") { 53 | TestHelper.create(object: StubRealmObject.self, inRealm: realm, amount: 5) { index, object in 54 | object.id = index 55 | } 56 | let objects = StubRealmObject.all() 57 | expect(objects).to(haveCount(5)) 58 | } 59 | 60 | it("should return a nil user if no id is found") { 61 | let another = StubRealmObject.get(withId: 1) 62 | expect(another).to(beNil()) 63 | } 64 | } 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /OokamiTests/Default.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Default.swift 3 | // Ookami 4 | // 5 | // Created by Maka on 8/11/16. 6 | // Copyright © 2016 Mikunj Varsani. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | 12 | class Default: QuickSpec { 13 | override func spec() { 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /OokamiTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /OokamiUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | source 'https://github.com/CocoaPods/Specs.git' 2 | platform :ios, '9.0' 3 | inhibit_all_warnings! 4 | use_frameworks! 5 | 6 | plugin 'cocoapods-keys', { 7 | :project => "Ookami", 8 | :keys => [ 9 | "KitsuClientKey", 10 | "KitsuClientSecret" 11 | ]} 12 | 13 | def project_pods 14 | pod 'RealmSwift', '~> 3.0.2' 15 | pod 'SwiftyJSON', '~> 3.1.4' 16 | pod 'Alamofire', '~> 4.5' 17 | pod 'Heimdallr', '~> 3.6.1' 18 | end 19 | 20 | def testing_pods 21 | pod 'Quick', '~> 1.1' 22 | pod 'Nimble', '~> 7.0' 23 | pod 'OHHTTPStubs', '~> 6.0' 24 | pod 'OHHTTPStubs/Swift' 25 | end 26 | 27 | def ui_pods 28 | pod 'Cartography', '~> 1.1' 29 | pod 'Kingfisher', '~> 3.13' 30 | pod 'Reusable', '~> 4.0' 31 | pod 'XLPagerTabStrip', '~> 8.0' 32 | pod 'NVActivityIndicatorView', '~> 3.7' 33 | pod 'BTNavigationDropdownMenu', :git => 'https://github.com/PhamBaTho/BTNavigationDropdownMenu.git', :branch => 'swift-3.0' 34 | pod 'DynamicColor', '~> 3.3' 35 | pod 'ActionSheetPicker-3.0', '~> 2.2.0' 36 | pod 'IQKeyboardManager', '5.0.0' 37 | pod 'Diff', '~> 0.5.3' 38 | pod 'SKPhotoBrowser', '~> 5.0' 39 | pod 'XCDYouTubeKit', '~> 2.5.5' 40 | pod 'DZNEmptyDataSet', '~> 1.8' 41 | pod 'FBSDKLoginKit', '~> 4.25' 42 | pod '1PasswordExtension', '~> 1.8.4' 43 | pod 'Siren', '~> 2.0.7' 44 | end 45 | 46 | target 'Ookami' do 47 | project_pods 48 | ui_pods 49 | end 50 | 51 | target 'OokamiTests' do 52 | testing_pods 53 | end 54 | 55 | target 'OokamiKit' do 56 | project_pods 57 | end 58 | 59 | target 'OokamiKitTests' do 60 | testing_pods 61 | end 62 | 63 | target 'OokamiUITests' do 64 | project_pods 65 | end 66 | 67 | post_install do |installer| 68 | installer.pods_project.targets.each do |target| 69 | target.build_configurations.each do |config| 70 | config.build_settings['SWIFT_VERSION'] = '3.0' 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ookami 2 | =================== 3 | An iOS show tracking app which uses Kitsu.io as a backend 4 | 5 | ## Requirements 6 | 7 | - Xcode 8 8 | - iOS 9 9 | 10 | ## Setup 11 | 12 | #### Requirements 13 | 14 | - [Cocoapods](https://guides.cocoapods.org/using/getting-started.html) 15 | - [Cocoapod-Keys](https://github.com/orta/cocoapods-keys) 16 | - Kitsu.io client key + secret 17 | 18 | To setup either clone or download the repo. After `cd` to the repo folder and type: 19 | 20 | pod install 21 | While installing the Cocoapod-Keys plugin will ask you to enter `KitsuClientKey` and `KitsuClientSecret`. Enter the client key and secret respectively. 22 | 23 | ## Deploying 24 | 25 | **Note:** This section is useful only if you are going to submit the app to the Appstore. Please follow the MIT License guidelines if you do decide to publish the app. 26 | 27 | **Author Note: Please do not publish the app as-is on the Appstore, make some modifications that will allow people to be able to distinguish the apps.** 28 | 29 | This project uses [fastlane](https://fastlane.tools/) for deploying to App store or TestFlight. 30 | However a few things will need to be changed if you decide to build your own version. 31 | 32 | #### Requirements 33 | - [fastlane](https://fastlane.tools/) 34 | - Apple developer account 35 | 36 | Make sure your developer account is valid (has subscription) before attempting to deploy the app. 37 | 38 | 1. Change the app indentifier from `com.mikunjvarsani.Ookami` to another unique identifier. This can be done easily by opening the project in Xcode and changing it in the project settings. This is to ensure that the app is treated as a whole new unique app. 39 | 2. Run `fastlane init` in the project directory and follow the prompts. After completion an `Appfile` will be created with all the necessary information. 40 | 3. Copy the contents of `Template-Fastfile` to the newly generated `Fastfile` 41 | 4. run `fastlane deliver init` which will create a `Deliverfile`. A Template `Deliverfile` has been provided, copy the contents of it and paste it in the new `Deliverfile`. Edit the values accordingly. 42 | 5. Finally run `fastlane provision` to create the app in Developer portal and Itunes Connect. 43 | 44 | To deploy the app simply run `fastlane release`. 45 | 46 | To deploy it for testing, simply run `fastlane beta` instead. 47 | 48 | ## Author 49 | Mikunj Varsani 50 | 51 | ## License 52 | Ookami is available under the MIT license. See the LICENSE file for more info. 53 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ================ 3 | # Installation 4 | 5 | Make sure you have the latest version of the Xcode command line tools installed: 6 | 7 | ``` 8 | xcode-select --install 9 | ``` 10 | 11 | ## Choose your installation method: 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
    HomebrewInstaller ScriptRubyGems
    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
    30 | 31 | # Available Actions 32 | ## iOS 33 | ### ios create 34 | ``` 35 | fastlane ios create 36 | ``` 37 | Create the app in Developer and Itunes Connect 38 | ### ios provision 39 | ``` 40 | fastlane ios provision 41 | ``` 42 | Creating a code signing certificate and provisioning profile 43 | ### ios release 44 | ``` 45 | fastlane ios release 46 | ``` 47 | Submit new app build to store 48 | ### ios beta 49 | ``` 50 | fastlane ios beta 51 | ``` 52 | Submit a new TestFlight Beta 53 | 54 | ---- 55 | 56 | This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. 57 | More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). 58 | The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 59 | -------------------------------------------------------------------------------- /fastlane/Snapfile: -------------------------------------------------------------------------------- 1 | # Uncomment the lines below you want to change by removing the # in the beginning 2 | 3 | # A list of devices you want to take the screenshots from 4 | devices([ 5 | "iPhone 6", 6 | "iPhone 6 Plus", 7 | "iPhone 5s", 8 | "iPad Pro (12.9-inch)", 9 | "iPad Pro (9.7-inch)", 10 | # "Apple TV 1080p" 11 | ]) 12 | 13 | languages([ 14 | "en-US", 15 | # "de-DE", 16 | # "it-IT", 17 | # ["pt", "pt_BR"] # Portuguese with Brazilian locale 18 | ]) 19 | 20 | # The name of the scheme which contains the UI Tests 21 | scheme "Ookami" 22 | 23 | # Where should the resulting screenshots be stored? 24 | output_directory "./fastlane/screenshots" 25 | 26 | clear_previous_screenshots true # remove the '#' to clear all previously generated screenshots before creating new ones 27 | concurrent_simulators true 28 | xcargs "-only-testing:OokamiUITests" 29 | 30 | # Choose which project/workspace to use 31 | # project "./Project.xcodeproj" 32 | # workspace "./Project.xcworkspace" 33 | 34 | # Arguments to pass to the app on launch. See https://github.com/fastlane/snapshot#launch-arguments 35 | # launch_arguments(["-favColor red"]) 36 | 37 | # For more information about all available options run 38 | # fastlane snapshot --help 39 | -------------------------------------------------------------------------------- /fastlane/Template-Deliverfile: -------------------------------------------------------------------------------- 1 | ###################### NOTE ###################### 2 | # Rename this to Deliverfile after filling it out 3 | 4 | ###################### More Options ###################### 5 | # If you want to have even more control, check out the documentation 6 | # https://github.com/fastlane/fastlane/blob/master/deliver/Deliverfile.md 7 | 8 | 9 | ###################### Automatically generated ###################### 10 | # Feel free to remove the following line if you use fastlane (which you should) 11 | 12 | app_identifier "[identifier]" # The bundle identifier of your app 13 | username "[apple id]" # your Apple ID user 14 | app_icon './fastlane/metadata/iTunesArtwork@2x.png' 15 | copyright "#{Time.now.year} Mikunj Varsani" 16 | 17 | price_tier 0 18 | 19 | app_review_information( 20 | first_name: "[Name]", 21 | last_name: "[Last Name]", 22 | phone_number: "[Number]", 23 | email_address: "[Email]", 24 | demo_user: "[Demo user]", 25 | demo_password: "[Demo pass]", 26 | notes: "App requires user to be logged in." 27 | ) 28 | 29 | submission_information({ 30 | export_compliance_encryption_updated: false, 31 | export_compliance_uses_encryption: false, 32 | content_rights_contains_third_party_content: false, 33 | add_id_info_uses_idfa: false 34 | }) 35 | 36 | app_rating_config_path "./fastlane/metadata/itunes_rating_config.json" 37 | -------------------------------------------------------------------------------- /fastlane/Template-Fastfile: -------------------------------------------------------------------------------- 1 | # Customise this file, documentation can be found here: 2 | # https://github.com/fastlane/fastlane/tree/master/fastlane/docs 3 | # All available actions: https://docs.fastlane.tools/actions 4 | # can also be listed using the `fastlane actions` command 5 | 6 | # Change the syntax highlighting to Ruby 7 | # All lines starting with a # are ignored when running `fastlane` 8 | 9 | # If you want to automatically update fastlane if a new version is available: 10 | # update_fastlane 11 | 12 | # This is the minimum version number required. 13 | # Update this, if you use features of a newer version 14 | fastlane_version "2.9.0" 15 | 16 | default_platform :ios 17 | 18 | platform :ios do 19 | 20 | before_all do 21 | end 22 | 23 | desc "Create the app in Developer and Itunes Connect" 24 | lane :create do 25 | produce( 26 | app_name: 'Ookami', 27 | language: 'English', 28 | app_version: '1.0', 29 | sku: 'mikunjVarsaniOokami' 30 | ) 31 | end 32 | 33 | desc "Creating a code signing certificate and provisioning profile" 34 | lane :provision do 35 | create 36 | cert 37 | sigh(force: true) 38 | end 39 | 40 | desc "Submit new app build to store" 41 | lane :release do 42 | cocoapods 43 | provision 44 | snapshot 45 | #frameit - Need to setup properley 46 | 47 | gym(scheme: "Ookami", 48 | configuration: "Release", 49 | silent: true, 50 | clean: true) 51 | 52 | deliver 53 | end 54 | 55 | desc "Submit a new TestFlight Beta" 56 | lane :beta do 57 | provision 58 | gym( 59 | configuration: "Release", 60 | scheme: "Ookami", 61 | output_directory: "Beta", 62 | output_name: "Ookami.ipa" # specify the name of the .ipa file to generate (including file extension) 63 | ) 64 | pilot( 65 | ipa: "Beta/Ookami.ipa", 66 | distribute_external: false 67 | ) 68 | end 69 | 70 | after_all do |lane| 71 | # This block is called, only if the executed lane was successful 72 | 73 | # slack( 74 | # message: "Successfully deployed new App Update." 75 | # ) 76 | end 77 | 78 | error do |lane, exception| 79 | # slack( 80 | # message: exception.message, 81 | # success: false 82 | # ) 83 | end 84 | end 85 | 86 | 87 | # More information about multiple platforms in fastlane: https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Platforms.md 88 | # All available actions: https://docs.fastlane.tools/actions 89 | 90 | # fastlane reports which actions are used 91 | # No personal data is recorded. Learn more at https://github.com/fastlane/enhancer 92 | -------------------------------------------------------------------------------- /fastlane/metadata/copyright.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Mikunj Varsani 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/description.txt: -------------------------------------------------------------------------------- 1 | Ookami is a show tracking app, made for Kitsu.io, that helps you manage what you are watching or reading very easily. 2 | 3 | Features: 4 | - Seamlessly syncs with Kitsu.io 5 | - Track both your Anime and Manga easily! 6 | - Advanced filters for discovering new shows and manga 7 | - See Highest Rated, Most Popular, Seasonal and Trending shows/manga 8 | 9 | Note: This app does not offer any streaming services. 10 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/keywords.txt: -------------------------------------------------------------------------------- 1 | Anime, Kitsu, Manga, Japanese, TV, Shows 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/marketing_url.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/name.txt: -------------------------------------------------------------------------------- 1 | Ookami - Kitsu Show Tracker 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/privacy_url.txt: -------------------------------------------------------------------------------- 1 | https://kitsu.io/privacy 2 | -------------------------------------------------------------------------------- /fastlane/metadata/en-US/release_notes.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/fastlane/metadata/en-US/release_notes.txt -------------------------------------------------------------------------------- /fastlane/metadata/en-US/support_url.txt: -------------------------------------------------------------------------------- 1 | https://kitsu.io/groups/ookami-app 2 | -------------------------------------------------------------------------------- /fastlane/metadata/iTunesArtwork@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mikunj/Ookami/e92a28d7f790c856a115f1aa1e4cbb656af96dd0/fastlane/metadata/iTunesArtwork@2x.png -------------------------------------------------------------------------------- /fastlane/metadata/itunes_rating_config.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "CARTOON_FANTASY_VIOLENCE": 1, 4 | "REALISTIC_VIOLENCE": 0, 5 | "PROLONGED_GRAPHIC_SADISTIC_REALISTIC_VIOLENCE": 0, 6 | "PROFANITY_CRUDE_HUMOR": 0, 7 | "MATURE_SUGGESTIVE": 0, 8 | "HORROR": 0, 9 | "MEDICAL_TREATMENT_INFO": 0, 10 | "ALCOHOL_TOBACCO_DRUGS": 0, 11 | "GAMBLING": 0, 12 | "SEXUAL_CONTENT_NUDITY": 1, 13 | "GRAPHIC_SEXUAL_CONTENT_NUDITY": 0, 14 | "UNRESTRICTED_WEB_ACCESS": 0, 15 | "GAMBLING_CONTESTS": 0 16 | } 17 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_category.txt: -------------------------------------------------------------------------------- 1 | Entertainment 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_first_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/primary_second_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_first_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/secondary_second_sub_category.txt: -------------------------------------------------------------------------------- 1 | 2 | --------------------------------------------------------------------------------