├── .swiftlint.yml ├── .travis.yml ├── Cartfile ├── Cartfile.private ├── Cartfile.resolved ├── LICENSE ├── Makefile ├── Resources ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── AppIcon-128.png │ │ ├── AppIcon-128@2x.png │ │ ├── AppIcon-16.png │ │ ├── AppIcon-16@2x.png │ │ ├── AppIcon-256.png │ │ ├── AppIcon-256@2x.png │ │ ├── AppIcon-32.png │ │ ├── AppIcon-32@2x.png │ │ ├── AppIcon-512.png │ │ ├── AppIcon-512@2x.png │ │ └── Contents.json │ ├── Contents.json │ ├── ascending.imageset │ │ ├── Ascending.png │ │ ├── Ascending@2x.png │ │ ├── Ascending@3x.png │ │ └── Contents.json │ ├── bookmark-off.imageset │ │ ├── Contents.json │ │ ├── bookmark-off.png │ │ ├── bookmark-off@2x.png │ │ └── bookmark-off@3x.png │ ├── bookmark-on.imageset │ │ ├── Contents.json │ │ ├── bookmark-on-1.png │ │ ├── bookmark-on.png │ │ └── bookmark-on@3x.png │ ├── descending.imageset │ │ ├── Contents.json │ │ ├── Descending.png │ │ ├── Descending@2x.png │ │ └── Descending@3x.png │ ├── next.imageset │ │ ├── Contents.json │ │ ├── Next.png │ │ ├── Next@2x.png │ │ └── Next@3x.png │ ├── plus.imageset │ │ ├── Contents.json │ │ └── Plus Math-96.png │ ├── previous.imageset │ │ ├── Contents.json │ │ ├── Previous.png │ │ ├── Previous@2x.png │ │ └── Previous@3x.png │ ├── search.imageset │ │ ├── Contents.json │ │ ├── Search.png │ │ ├── Search@2x.png │ │ └── Search@3x.png │ ├── zoom-in.imageset │ │ ├── Contents.json │ │ ├── ZoomIn.png │ │ ├── ZoomIn@2x.png │ │ └── ZoomIn@3x.png │ └── zoom-out.imageset │ │ ├── Contents.json │ │ ├── ZoomOut.png │ │ ├── ZoomOut@2x.png │ │ └── ZoomOut@3x.png └── screenshot.png ├── Supporting Files ├── Components.plist ├── Info.dev.plist └── Info.plist ├── Yomu.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Yomu Development.xcscheme │ └── Yomu.xcscheme ├── Yomu ├── API │ ├── MangaEdenAPI.swift │ └── YomuAPI.swift ├── AppDelegate.swift ├── Common │ ├── Extensions │ │ ├── NSColor.swift │ │ ├── NSImageView.swift │ │ ├── NSProgressIndicator.swift │ │ ├── NSURL.swift │ │ ├── NSView.swift │ │ ├── RxMoyaResponse.swift │ │ └── String.swift │ ├── Models │ │ └── ImageUrl.swift │ ├── Utils │ │ ├── Config.swift │ │ ├── Database.swift │ │ ├── DisposeBag.swift │ │ ├── Function.swift │ │ └── Router.swift │ └── Views │ │ ├── ActionButton.swift │ │ ├── MenuableCollectionView.swift │ │ ├── NoteLabel.swift │ │ ├── SearchTextInput.swift │ │ ├── StickyHeader.swift │ │ ├── TextInput.swift │ │ └── TextInputContainer.swift └── Screens │ ├── ChapterList │ ├── Chapter.swift │ ├── ChapterCollection.xib │ ├── ChapterCollectionViewController.swift │ ├── ChapterCollectionViewModel.swift │ ├── ChapterItem.swift │ ├── ChapterItem.xib │ └── ChapterViewModel.swift │ ├── ChapterPageList │ ├── ChapterPage.swift │ ├── ChapterPageCollection.xib │ ├── ChapterPageCollectionViewController.swift │ ├── ChapterPageCollectionViewModel.swift │ ├── ChapterPageItem.swift │ ├── ChapterPageItem.xib │ └── ChapterPageViewModel.swift │ ├── MangaList │ ├── Manga.swift │ ├── MangaCollection.xib │ ├── MangaCollectionViewController.swift │ ├── MangaCollectionViewModel.swift │ ├── MangaItem.swift │ ├── MangaItem.xib │ ├── MangaRealm.swift │ └── MangaViewModel.swift │ ├── Root │ ├── ChapterPageContainer.swift │ ├── Main.storyboard │ ├── MangaContainerViewController.swift │ ├── Routes.swift │ └── YomuWindowController.swift │ └── SearchManga │ ├── SearchedManga.swift │ ├── SearchedMangaCollection.xib │ ├── SearchedMangaCollectionViewController.swift │ ├── SearchedMangaCollectionViewModel.swift │ ├── SearchedMangaItem.swift │ ├── SearchedMangaItem.xib │ └── SearchedMangaViewModel.swift ├── YomuTests ├── .swiftlint.yml ├── Info.plist ├── Models │ ├── ChapterPageSpec.swift │ ├── ChapterSpec.swift │ └── MangaSpec.swift ├── Utils │ └── JSONFileReader.swift ├── ViewModels │ ├── ChapterPageViewModelSpec.swift │ ├── MangaViewModelSpec.swift │ └── SearchedMangaViewModelSpec.swift └── stubs │ └── manga.json └── readme.md /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | line_length: 2 | warning: 120 3 | error: 150 4 | disabled_rules: 5 | - variable_name 6 | - force_cast 7 | - force_try 8 | - function_body_length 9 | - syntactic_sugar 10 | - shorthand_operator 11 | included: 12 | - Yomu 13 | - YomuTests 14 | excluded: # paths to ignore during linting. overridden by `included`. 15 | - Carthage 16 | - Pods 17 | - R.generated.swift 18 | - YomuTests/Utils/JSONFileReader.swift 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | before_install: 2 | - type swiftlint >/dev/null 2>&1 || (brew update && brew install swiftlint) # Install swiftlint if it's not installed 3 | - curl -OlL "https://github.com/Carthage/Carthage/releases/download/0.18.1/Carthage.pkg" 4 | - sudo installer -pkg "Carthage.pkg" -target / 5 | - rm "Carthage.pkg" 6 | - carthage version # Print version for debugging purposes 7 | matrix: 8 | include: 9 | - os: osx 10 | language: objective-c 11 | osx_image: xcode9.4 12 | script: 13 | - travis_retry make bootstrap 14 | - make test 15 | env: JOB=CI_BUILD_TEST 16 | - os: osx 17 | language: objective-c 18 | osx_image: xcode9.4 19 | script: 20 | - travis_retry make package 21 | env: JOB=CI_BUILD_PACKAGE 22 | deploy: 23 | provider: releases 24 | api_key: 25 | secure: rv3j7nFh0Pwszmji5jrL9l4RFiWPpSMc8gfyuRosGb0QNFYAI5R/S/TMGS4hD6yxQ4fN3GqJsI0Z32/jhlVODMkuz+9Dgw/8WQK0EQ/sEGKdnt41aGv5nJmSrNrhXZNyD/Iz8nBGRadIpEIbkqK1lnGT0RlK/D1ZFCE5CumJvxr1mNa9L1crwexgh7jWkDoARjfRRhW63M57zR5CzefAaVhYES/U1P8ZtuWN6xe2ApZwE+VZlnh01OznKEb22gAhr4tV1ywm3x6F31xCpw84CHGP5byl6QkMp9fQbl8XL+oe0D55FaPSeU8oVeDqaSD3uvv8EiTE9uI7DbEtrhJg1dV04BOxkWmr19h0UawG/9xPhY2xNr5B7BPt1yLG2XwOgTPhDYI7BhAB4mu19fC+P1U689Mi0UgixtLq5MKB9+TKVGdcotf7EPZWZ7VxDqCo8GhMYNOB4DUvr0BkHBmDWCG93+g6odH54ZCYNIEfCt+S0YimdriRMSWfOgX/6kBQ4jMMEjQjFO8Jm2462gI8BzVKW6ZUHGFjKAd4ND7LgClbNtUqs5mrYIpIdJVR5w8USq6CKazrTacgZ7wUxbG2blhZ6DQ7kDIk8ZVNZgRAdVcx2pNZ9C6cxi4xCYRSXmV8Vz4d82gHzpX/Y+7QG9DW2Q7b9VLN4kxZnYWX4CwVwlY= 26 | file: Yomu.pkg 27 | skip_cleanup: true 28 | on: 29 | tags: true 30 | repo: sendyhalim/Yomu 31 | condition: $JOB = CI_BUILD_PACKAGE 32 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "onevcat/Kingfisher" ~> 4.10.0 2 | github "typelift/Swiftz" "master" 3 | github "ReactiveX/RxSwift" ~> 4.3.1 4 | github "Moya/Moya" ~> 11.0.2 5 | github "Thoughtbot/Argo" "master" 6 | github "Thoughtbot/Curry" "master" 7 | github "realm/realm-cocoa" ~> 3.10.0 8 | github "RxSwiftCommunity/RxRealm" "master" 9 | github "hyperoslo/Hue" ~> 3.0.1 10 | github "robb/Cartography" ~> 3.1.0 11 | github "sendyhalim/OrderedSet" ~> 0.0.3 12 | -------------------------------------------------------------------------------- /Cartfile.private: -------------------------------------------------------------------------------- 1 | github "Quick/Quick" "master" 2 | github "Quick/Nimble" "master" 3 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "Alamofire/Alamofire" "4.7.3" 2 | github "Moya/Moya" "11.0.2" 3 | github "Quick/Nimble" "c72edbbd6358090f8f1c4b6141538ebc88d167d9" 4 | github "Quick/Quick" "044ea18d202369e33e1fc1849498a7824b89f1c4" 5 | github "ReactiveCocoa/ReactiveSwift" "3.1.0" 6 | github "ReactiveX/RxSwift" "4.3.1" 7 | github "RxSwiftCommunity/RxRealm" "e52026ac3c7db08174940d6eb7c100d62e08ee9e" 8 | github "Thoughtbot/Argo" "ceb3775cfb22a3f0bf7f5aac1d71e3a89627ba36" 9 | github "Thoughtbot/Curry" "b6bf27ec9d711f607a8c7da9ca69ee9eaa201a22" 10 | github "antitypical/Result" "3.2.4" 11 | github "hyperoslo/Hue" "3.0.1" 12 | github "onevcat/Kingfisher" "4.10.0" 13 | github "realm/realm-cocoa" "v3.10.0" 14 | github "robb/Cartography" "3.1.0" 15 | github "sendyhalim/OrderedSet" "0.0.4" 16 | github "thoughtbot/Runes" "v4.1.1" 17 | github "typelift/Swiftz" "556236ad2d7c7420eea4301cf69b09575905a508" 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016 Sendy Halim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | platform = --platform macos 2 | xcode_flags = -project "Yomu.xcodeproj" -scheme "Yomu" -configuration "Release" DSTROOT=/tmp/Yomu.dst 3 | xcode_flags_test = -project "Yomu.xcodeproj" -scheme "Yomu" -configuration "Debug" 4 | components_plist = "Supporting Files/Components.plist" 5 | temporary_dir = /tmp/Yomu.dst 6 | output_package_name = Yomu.pkg 7 | 8 | bootstrap: 9 | carthage bootstrap $(platform) 10 | 11 | update: 12 | carthage update $(platform) 13 | 14 | build: 15 | carthage build $(platform) 16 | 17 | synx: 18 | synx Yomu.xcodeproj 19 | 20 | clean: 21 | rm -rf $(temporary_dir) 22 | rm -f $(output_package_name) 23 | xcodebuild $(xcode_flags) clean 24 | 25 | clean_realm: 26 | rm -rf ~/Library/Application\ Support/com.sendyhalim.Yomu/*.realm* 27 | 28 | test: clean 29 | xcodebuild $(xcode_flags_test) test 30 | 31 | installables: clean bootstrap 32 | xcodebuild $(xcode_flags) install 33 | 34 | lint: 35 | swiftlint 36 | 37 | package: installables 38 | pkgbuild \ 39 | --component-plist $(components_plist) \ 40 | --identifier "com.sendyhalim.yomu" \ 41 | --install-location "/" \ 42 | --root $(temporary_dir) \ 43 | $(output_package_name) 44 | 45 | 46 | .PHONY: bootstrap update synx clean test installables lint package 47 | -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-128@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-16@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-256@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-32@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "16x16", 5 | "idiom" : "mac", 6 | "filename" : "AppIcon-16.png", 7 | "scale" : "1x" 8 | }, 9 | { 10 | "size" : "16x16", 11 | "idiom" : "mac", 12 | "filename" : "AppIcon-16@2x.png", 13 | "scale" : "2x" 14 | }, 15 | { 16 | "size" : "32x32", 17 | "idiom" : "mac", 18 | "filename" : "AppIcon-32.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "32x32", 23 | "idiom" : "mac", 24 | "filename" : "AppIcon-32@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "128x128", 29 | "idiom" : "mac", 30 | "filename" : "AppIcon-128.png", 31 | "scale" : "1x" 32 | }, 33 | { 34 | "size" : "128x128", 35 | "idiom" : "mac", 36 | "filename" : "AppIcon-128@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "256x256", 41 | "idiom" : "mac", 42 | "filename" : "AppIcon-256.png", 43 | "scale" : "1x" 44 | }, 45 | { 46 | "size" : "256x256", 47 | "idiom" : "mac", 48 | "filename" : "AppIcon-256@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "512x512", 53 | "idiom" : "mac", 54 | "filename" : "AppIcon-512.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "512x512", 59 | "idiom" : "mac", 60 | "filename" : "AppIcon-512@2x.png", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/ascending.imageset/Ascending.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/ascending.imageset/Ascending.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/ascending.imageset/Ascending@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/ascending.imageset/Ascending@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/ascending.imageset/Ascending@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/ascending.imageset/Ascending@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/ascending.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Ascending.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Ascending@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Ascending@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-off.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "bookmark-off.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "bookmark-off@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "bookmark-off@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-off.imageset/bookmark-off@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-on.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "bookmark-on-1.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "bookmark-on.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "bookmark-on@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on-1.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/bookmark-on.imageset/bookmark-on@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/descending.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Descending.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Descending@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Descending@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/descending.imageset/Descending.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/descending.imageset/Descending.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/descending.imageset/Descending@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/descending.imageset/Descending@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/descending.imageset/Descending@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/descending.imageset/Descending@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/next.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Next.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Next@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Next@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/next.imageset/Next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/next.imageset/Next.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/next.imageset/Next@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/next.imageset/Next@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/next.imageset/Next@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/next.imageset/Next@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/plus.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Plus Math-96.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/plus.imageset/Plus Math-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/plus.imageset/Plus Math-96.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/previous.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Previous.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Previous@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Previous@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/previous.imageset/Previous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/previous.imageset/Previous.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/previous.imageset/Previous@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/previous.imageset/Previous@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/previous.imageset/Previous@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/previous.imageset/Previous@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/search.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Search.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "Search@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "Search@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/search.imageset/Search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/search.imageset/Search.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/search.imageset/Search@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/search.imageset/Search@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/search.imageset/Search@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/search.imageset/Search@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-in.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "ZoomIn.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "ZoomIn@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "ZoomIn@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-in.imageset/ZoomIn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-in.imageset/ZoomIn.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-in.imageset/ZoomIn@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-in.imageset/ZoomIn@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-in.imageset/ZoomIn@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-in.imageset/ZoomIn@3x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-out.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "ZoomOut.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "ZoomOut@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "ZoomOut@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-out.imageset/ZoomOut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-out.imageset/ZoomOut.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-out.imageset/ZoomOut@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-out.imageset/ZoomOut@2x.png -------------------------------------------------------------------------------- /Resources/Assets.xcassets/zoom-out.imageset/ZoomOut@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/Assets.xcassets/zoom-out.imageset/ZoomOut@3x.png -------------------------------------------------------------------------------- /Resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sendyhalim/Yomu/1a23a0fbf54bb93548b2c2b5d86b221bb179cde1/Resources/screenshot.png -------------------------------------------------------------------------------- /Supporting Files/Components.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | BundleHasStrictIdentifier 7 | 8 | BundleIsRelocatable 9 | 10 | BundleIsVersionChecked 11 | 12 | BundleOverwriteAction 13 | upgrade 14 | ChildBundles 15 | 16 | 17 | BundleOverwriteAction 18 | 19 | RootRelativeBundlePath 20 | Applications/Yomu.app/Contents/Frameworks/Alamofire.framework 21 | 22 | 23 | BundleOverwriteAction 24 | 25 | RootRelativeBundlePath 26 | Applications/Yomu.app/Contents/Frameworks/Argo.framework 27 | 28 | 29 | BundleOverwriteAction 30 | 31 | RootRelativeBundlePath 32 | Applications/Yomu.app/Contents/Frameworks/Cartography.framework 33 | 34 | 35 | BundleOverwriteAction 36 | 37 | RootRelativeBundlePath 38 | Applications/Yomu.app/Contents/Frameworks/Curry.framework 39 | 40 | 41 | BundleOverwriteAction 42 | 43 | RootRelativeBundlePath 44 | Applications/Yomu.app/Contents/Frameworks/Hue.framework 45 | 46 | 47 | BundleOverwriteAction 48 | 49 | RootRelativeBundlePath 50 | Applications/Yomu.app/Contents/Frameworks/Kingfisher.framework 51 | 52 | 53 | BundleOverwriteAction 54 | 55 | RootRelativeBundlePath 56 | Applications/Yomu.app/Contents/Frameworks/Moya.framework 57 | 58 | 59 | BundleOverwriteAction 60 | 61 | RootRelativeBundlePath 62 | Applications/Yomu.app/Contents/Frameworks/Realm.framework 63 | 64 | 65 | BundleOverwriteAction 66 | 67 | RootRelativeBundlePath 68 | Applications/Yomu.app/Contents/Frameworks/RealmSwift.framework 69 | 70 | 71 | BundleOverwriteAction 72 | 73 | RootRelativeBundlePath 74 | Applications/Yomu.app/Contents/Frameworks/Result.framework 75 | 76 | 77 | BundleOverwriteAction 78 | 79 | RootRelativeBundlePath 80 | Applications/Yomu.app/Contents/Frameworks/RxCocoa.framework 81 | 82 | 83 | BundleOverwriteAction 84 | 85 | RootRelativeBundlePath 86 | Applications/Yomu.app/Contents/Frameworks/RxMoya.framework 87 | 88 | 89 | BundleOverwriteAction 90 | 91 | RootRelativeBundlePath 92 | Applications/Yomu.app/Contents/Frameworks/RxRealm.framework 93 | 94 | 95 | BundleOverwriteAction 96 | 97 | RootRelativeBundlePath 98 | Applications/Yomu.app/Contents/Frameworks/RxSwift.framework 99 | 100 | 101 | BundleOverwriteAction 102 | 103 | RootRelativeBundlePath 104 | Applications/Yomu.app/Contents/Frameworks/Swiftz.framework 105 | 106 | 107 | RootRelativeBundlePath 108 | Applications/Yomu.app 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /Supporting Files/Info.dev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1 25 | LSMinimumSystemVersion 26 | $(MACOSX_DEPLOYMENT_TARGET) 27 | NSHumanReadableCopyright 28 | Copyright © 2016 Sendy Halim. All rights reserved. 29 | NSMainStoryboardFile 30 | Main 31 | NSPrincipalClass 32 | NSApplication 33 | NSAppTransportSecurity 34 | 35 | NSAllowsArbitraryLoads 36 | 37 | 38 | YomuAPI 39 | http://localhost:3099/api 40 | 41 | 42 | -------------------------------------------------------------------------------- /Supporting Files/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.6.1 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | 1 25 | LSApplicationCategoryType 26 | public.app-category.entertainment 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSAppTransportSecurity 30 | 31 | NSAllowsArbitraryLoads 32 | 33 | 34 | NSHumanReadableCopyright 35 | Copyright © 2016 Sendy Halim. All rights reserved. 36 | NSMainStoryboardFile 37 | Main 38 | NSPrincipalClass 39 | NSApplication 40 | YomuAPI 41 | https://yomu-server.herokuapp.com/api 42 | 43 | 44 | -------------------------------------------------------------------------------- /Yomu.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Yomu.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Yomu.xcodeproj/xcshareddata/xcschemes/Yomu Development.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 73 | 75 | 81 | 82 | 83 | 84 | 86 | 87 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /Yomu.xcodeproj/xcshareddata/xcschemes/Yomu.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 49 | 50 | 51 | 52 | 53 | 54 | 64 | 66 | 72 | 73 | 74 | 75 | 76 | 77 | 83 | 85 | 91 | 92 | 93 | 94 | 96 | 97 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /Yomu/API/MangaEdenAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaEdenAPI.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Moya 11 | import RxSwift 12 | import RxMoya 13 | 14 | enum MangaEdenAPI { 15 | case mangaDetail(String) 16 | case chapterPages(String) 17 | } 18 | 19 | extension MangaEdenAPI: TargetType { 20 | var baseURL: URL { return URL(string: "http://www.mangaeden.com/api")! } 21 | 22 | var path: String { 23 | switch self { 24 | case .mangaDetail(let id): 25 | return "/manga/\(id)" 26 | 27 | case .chapterPages(let id): 28 | return "/chapter/\(id)" 29 | } 30 | } 31 | 32 | var method: Moya.Method { 33 | return .get 34 | } 35 | 36 | var parameterEncoding: ParameterEncoding { 37 | return URLEncoding.default 38 | } 39 | 40 | var headers: [String: String]? { 41 | return nil 42 | } 43 | 44 | var task: Task { 45 | return Task.requestPlain 46 | } 47 | 48 | var sampleData: Data { 49 | return "{}".UTF8EncodedData 50 | } 51 | } 52 | 53 | struct MangaEden { 54 | fileprivate static let provider = MoyaProvider() 55 | 56 | static func request(_ api: MangaEdenAPI) -> Single { 57 | return provider.rx.request(api) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Yomu/API/YomuAPI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchMangaAPI.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Moya 11 | import RxMoya 12 | import RxSwift 13 | 14 | enum YomuAPI { 15 | case search(String) 16 | } 17 | 18 | extension YomuAPI: TargetType { 19 | var baseURL: URL { return URL(string: Config.YomuAPI)! } 20 | 21 | var path: String { 22 | switch self { 23 | case .search: 24 | return "/search" 25 | } 26 | } 27 | 28 | var method: Moya.Method { 29 | return .get 30 | } 31 | 32 | var parameterEncoding: ParameterEncoding { 33 | return URLEncoding.default 34 | } 35 | 36 | var headers: [String: String]? { 37 | return nil 38 | } 39 | 40 | var task: Task { 41 | switch self { 42 | case .search(let searchTerm): 43 | let parameters = [ 44 | "term": searchTerm 45 | ] 46 | 47 | return Task.requestParameters(parameters: parameters, encoding: URLEncoding.queryString) 48 | } 49 | } 50 | 51 | var sampleData: Data { 52 | return "[]".UTF8EncodedData 53 | } 54 | } 55 | 56 | struct Yomu { 57 | fileprivate static let provider = MoyaProvider() 58 | 59 | static func request(_ api: YomuAPI) -> Single { 60 | return provider.rx.request(api) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Yomu/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | @NSApplicationMain 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | var mainWindow: NSWindow! 14 | 15 | func applicationDidFinishLaunching(_ aNotification: Notification) { 16 | mainWindow = NSApplication.shared.windows.first! 17 | mainWindow.titleVisibility = NSWindow.TitleVisibility.hidden 18 | mainWindow.titlebarAppearsTransparent = true 19 | mainWindow.styleMask = [NSWindow.StyleMask.fullSizeContentView, mainWindow.styleMask] 20 | mainWindow.setFrame(NSScreen.main!.frame, display: true) 21 | } 22 | 23 | func applicationWillTerminate(_ aNotification: Notification) { 24 | // Insert code here to tear down your application 25 | } 26 | 27 | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { 28 | mainWindow.makeKeyAndOrderFront(sender) 29 | 30 | return true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/NSColor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSColor.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/7/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | extension NSColor { 12 | /// Create an `NSColor` from hexadecimals 13 | /// http://stackoverflow.com/questions/24074257/how-to-use-uicolorfromrgb-value-in-swift 14 | /// 15 | /// - parameter hex: Hexadecimal value e.g. 0xEFEFEF 16 | /// 17 | /// - returns: `NSColor` 18 | static func fromHex(_ hex: UInt) -> NSColor { 19 | return NSColor( 20 | calibratedRed: CGFloat((hex & 0xFF0000) >> 16) / 255.0, 21 | green: CGFloat((hex & 0x00FF00) >> 8) / 255.0, 22 | blue: CGFloat(hex & 0x0000FF) / 255.0, 23 | alpha: CGFloat(1.0) 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/NSImageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSImageView.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/7/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Kingfisher 11 | 12 | private var centerPointKey: Void? 13 | 14 | extension NSImageView { 15 | /// Center point of `NSImageView` in its parent view coordinate system 16 | fileprivate var centerPoint: CGPoint? { 17 | let value = objc_getAssociatedObject(self, ¢erPointKey) as? NSValue 18 | 19 | return value?.pointValue 20 | } 21 | 22 | /// An abstraction for Kingfisher. 23 | /// It helps to provide accurate arity thus it can be used with RxCocoa's `drive` easily 24 | /// `someUrl.drive(onNext: imageView.setImageUrl)` 25 | /// 26 | /// - parameter url: Image url 27 | func setImageWithUrl(_ url: URL) { 28 | kf.setImage(with: url) 29 | } 30 | 31 | /// Set center point of `NSImageView` in its parent view coordinate system 32 | /// 33 | /// - parameter point: Center Point 34 | fileprivate func setCenterPoint(_ point: CGPoint) { 35 | objc_setAssociatedObject(self, ¢erPointKey, NSValue(point: point), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) 36 | } 37 | 38 | /// There's a bug with NSProgressIndicator when cell gets reused it doesn't 39 | /// update the frame of progress indicator, so we need to update it manually. 40 | /// https://github.com/onevcat/Kingfisher/issues/395 41 | open override func viewWillDraw() { 42 | let indicator = kf.indicator 43 | let _centerPoint = centerPoint 44 | let newCenterPoint = CGPoint(x: bounds.midX, y: bounds.midY) 45 | 46 | if (_centerPoint == nil || newCenterPoint != _centerPoint) && indicator != nil { 47 | let indicatorFrame = indicator!.view.frame 48 | indicator!.view.frame = CGRect( 49 | x: newCenterPoint.x - indicatorFrame.size.width / 2.0, 50 | y: newCenterPoint.y - indicatorFrame.size.height / 2.0, 51 | width: indicatorFrame.size.width, 52 | height: indicatorFrame.size.height 53 | ) 54 | 55 | setCenterPoint(newCenterPoint) 56 | } 57 | } 58 | 59 | open override func viewDidHide() { 60 | super.viewDidHide() 61 | 62 | // Sometimes the indicator is still being showed. 63 | // This could happen when there's a task to fetch image and indicator is shown, 64 | // then the fetch task is cleaned and we haven't hidden the indicator yet, 65 | // the indicator will be buggy (kept shown) if we're re-using the image view 66 | kf.indicator?.stopAnimatingView() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/NSProgressIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSProgressIndicator.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/19/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | extension NSProgressIndicator { 12 | /// This method will start animating if given `true`, `stopAnimation` if false. 13 | /// 14 | /// - parameter shouldAnimate: Bool 15 | func animating(_ shouldAnimate: Bool) { 16 | if shouldAnimate { 17 | startAnimation(nil) 18 | } else { 19 | stopAnimation(nil) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/NSURL.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NSURL+Argo.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/10/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Swiftz 11 | 12 | extension URL: Argo.Decodable { 13 | public static func decode(_ json: JSON) -> Decoded { 14 | switch json { 15 | case JSON.string(let url): 16 | return URL(string: url).map(pure) ?? .typeMismatch( 17 | expected: "A String that is convertible to NSURL", 18 | actual: url 19 | ) 20 | 21 | default: 22 | return .typeMismatch(expected: "String", actual: json) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/NSView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextInput.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/7/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | struct Border { 12 | let position: BorderPosition 13 | let width: CGFloat 14 | let color: NSColor 15 | let radius: CGFloat 16 | 17 | init(position: BorderPosition, width: CGFloat, color: NSColor, radius: CGFloat = 0.0) { 18 | self.position = position 19 | self.width = width 20 | self.color = color 21 | self.radius = radius 22 | } 23 | } 24 | 25 | enum BorderPosition { 26 | case all 27 | case left 28 | case top 29 | case right 30 | case bottom 31 | } 32 | 33 | extension NSView { 34 | /// Draw border based on the given `Border` spec 35 | /// 36 | /// - parameter border: `Border` spec 37 | func drawBorder(_ border: Border) { 38 | wantsLayer = true 39 | 40 | switch border.position { 41 | case .all: 42 | drawBorderAtLeft(width: border.width, radius: border.radius, color: border.color) 43 | drawBorderAtTop(width: border.width, radius: border.radius, color: border.color) 44 | drawBorderAtRight(width: border.width, radius: border.radius, color: border.color) 45 | drawBorderAtBottom(width: border.width, radius: border.radius, color: border.color) 46 | 47 | case .left: 48 | drawBorderAtLeft(width: border.width, radius: border.radius, color: border.color) 49 | 50 | case .top: 51 | drawBorderAtTop(width: border.width, radius: border.radius, color: border.color) 52 | 53 | case .right: 54 | drawBorderAtRight(width: border.width, radius: border.radius, color: border.color) 55 | 56 | case .bottom: 57 | drawBorderAtBottom(width: border.width, radius: border.radius, color: border.color) 58 | } 59 | } 60 | 61 | /// Draw a border (rectangle) at left 62 | /// 63 | /// - parameter borderWidth: Border width in point 64 | /// - parameter radius: Border radius 65 | /// - parameter color: Border color 66 | fileprivate func drawBorderAtLeft(width borderWidth: CGFloat, radius: CGFloat, color: NSColor) { 67 | let borderFrame = CGRect( 68 | x: 0, 69 | y: 0, 70 | width: borderWidth, 71 | height: frame.size.height 72 | ) 73 | 74 | drawBorder(frame: borderFrame, width: borderWidth, radius: radius, color: color) 75 | } 76 | 77 | /// Draw a border (rectangle) at top 78 | /// 79 | /// - parameter borderWidth: Border width in point 80 | /// - parameter radius: Border radius 81 | /// - parameter color: Border color 82 | fileprivate func drawBorderAtTop(width borderWidth: CGFloat, radius: CGFloat, color: NSColor) { 83 | let yPosition = isFlipped ? 0 : frame.size.height - borderWidth 84 | let borderFrame = CGRect( 85 | x: 0, 86 | y: yPosition, 87 | width: frame.size.width, 88 | height: borderWidth 89 | ) 90 | 91 | drawBorder(frame: borderFrame, width: borderWidth, radius: radius, color: color) 92 | } 93 | 94 | /// Draw a border (rectangle) at right 95 | /// 96 | /// - parameter borderWidth: Border width in point 97 | /// - parameter radius: Border radius 98 | /// - parameter color: Border color 99 | fileprivate func drawBorderAtRight(width borderWidth: CGFloat, radius: CGFloat, color: NSColor) { 100 | let borderFrame = CGRect( 101 | x: frame.size.width - borderWidth, 102 | y: 0, 103 | width: borderWidth, 104 | height: frame.size.height 105 | ) 106 | 107 | drawBorder(frame: borderFrame, width: borderWidth, radius: radius, color: color) 108 | } 109 | 110 | /// Draw a border (rectangle) at bottom 111 | /// 112 | /// - parameter borderWidth: Border width in point 113 | /// - parameter radius: Border radius 114 | /// - parameter color: Border color 115 | fileprivate func drawBorderAtBottom(width borderWidth: CGFloat, radius: CGFloat, color: NSColor) { 116 | let yPosition = isFlipped ? frame.size.height - borderWidth : 0 117 | let borderFrame = CGRect( 118 | x: 0, 119 | y: yPosition, 120 | width: frame.size.width, 121 | height: borderWidth 122 | ) 123 | 124 | drawBorder(frame: borderFrame, width: borderWidth, radius: radius, color: color) 125 | } 126 | 127 | /// Draw border based on the given frame, will use layer to draw the border. 128 | /// 129 | /// - parameter borderFrame: Border layer frame 130 | /// - parameter borderWidth: Border width in point 131 | /// - parameter radius: Border radius 132 | /// - parameter color: Border color 133 | fileprivate func drawBorder( 134 | frame borderFrame: CGRect, 135 | width: CGFloat, 136 | radius: CGFloat, 137 | color: NSColor 138 | ) { 139 | let borderLayer = CALayer() 140 | 141 | borderLayer.borderColor = color.cgColor 142 | borderLayer.masksToBounds = true 143 | borderLayer.borderWidth = width 144 | borderLayer.cornerRadius = radius 145 | borderLayer.frame = borderFrame 146 | 147 | layer?.addSublayer(borderLayer) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/RxMoyaResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RxMoyaResponse.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import RxSwift 11 | import RxMoya 12 | import Moya 13 | 14 | extension Response { 15 | /// Transform response data to a type. 16 | /// 17 | /// - throws: Decoding error message 18 | /// 19 | /// - returns: Transformed data from response 20 | func map() throws -> T where T == T.DecodedType { 21 | let json = try mapJSON() 22 | let decoded: Decoded = decode(json) 23 | 24 | return try decodedValue(decoded) 25 | } 26 | 27 | /// Transform response data with specific key in json to a given type 28 | /// 29 | /// - parameter rootKey: Key in json 30 | /// 31 | /// - throws: Decoding error message 32 | /// 33 | /// - returns: Transformed data from response 34 | func map(withRootKey rootKey: String) throws -> T where T == T.DecodedType { 35 | let dict = try mapDictionary() 36 | let decoded: Decoded = decode(dict, rootKey: rootKey) 37 | 38 | return try decodedValue(decoded) 39 | } 40 | 41 | /// Transform response data with specific key in json to an array of given type 42 | /// 43 | /// - parameter rootKey: Key in json 44 | /// 45 | /// - throws: Decoding error message 46 | /// 47 | /// - returns: Transformed data from response 48 | func mapArray( 49 | withRootKey rootKey: String 50 | ) throws -> [T] where T == T.DecodedType { 51 | let dict = try mapDictionary() 52 | let decoded: Decoded<[T]> = decode(dict, rootKey: rootKey) 53 | 54 | return try decodedValue(decoded) 55 | } 56 | 57 | /// Map response data as dictionary 58 | /// 59 | /// - throws: `mapJSON()` error message 60 | /// 61 | /// - returns: Dictionary 62 | fileprivate func mapDictionary() throws -> [String: AnyObject] { 63 | let json = try mapJSON() 64 | 65 | return json as? [String: AnyObject] ?? [:] 66 | } 67 | 68 | /// Extract the value from `Decoded` context 69 | /// 70 | /// - parameter decoded: `Decoded` 71 | /// 72 | /// - throws: Decoding error message 73 | /// 74 | /// - returns: Extracted value 75 | fileprivate func decodedValue(_ decoded: Decoded) throws -> T { 76 | switch decoded { 77 | case .success(let value): 78 | return value 79 | 80 | case .failure(let error): 81 | throw error 82 | } 83 | } 84 | } 85 | 86 | extension PrimitiveSequence where TraitType == SingleTrait, ElementType == Response { 87 | func map(_ type: T.Type) -> PrimitiveSequence where T == T.DecodedType { 88 | return map { 89 | try $0.map() 90 | } 91 | } 92 | 93 | func map( 94 | _ type: T.Type, 95 | withRootKey rootKey: String 96 | ) -> PrimitiveSequence where T == T.DecodedType { 97 | return map { 98 | try $0.map(withRootKey: rootKey) 99 | } 100 | } 101 | 102 | func mapArray( 103 | _ type: T.Type, 104 | withRootKey rootKey: String 105 | ) -> PrimitiveSequence where T == T.DecodedType { 106 | return map { 107 | return try $0.mapArray(withRootKey: rootKey) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Yomu/Common/Extensions/String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | var UTF8EncodedData: Data { 13 | return self.data(using: String.Encoding.utf8)! 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Yomu/Common/Models/ImageUrl.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageUrl.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/10/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Argo 11 | 12 | /// A data structure that represents image url that points to Mangaeden api 13 | /// docs: http://www.mangaeden.com/api/ 14 | struct ImageUrl: CustomStringConvertible { 15 | static let prefix = "https://cdn.mangaeden.com/mangasimg" 16 | let endpoint: String 17 | 18 | var description: String { 19 | let characters = CharacterSet(charactersIn: "/") 20 | let _endpoint = endpoint.trimmingCharacters(in: characters) 21 | 22 | return "\(ImageUrl.prefix)/\(_endpoint)" 23 | } 24 | 25 | var url: URL { 26 | return URL(string: self.description)! 27 | } 28 | } 29 | 30 | extension ImageUrl: Argo.Decodable { 31 | static func decode(_ json: JSON) -> Decoded { 32 | switch json { 33 | case JSON.string(let endpoint): 34 | return pure(ImageUrl(endpoint: endpoint)) 35 | 36 | default: 37 | return .typeMismatch(expected: "String endpoint", actual: json) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Yomu/Common/Utils/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Hue 11 | 12 | private let bundle = Bundle.main 13 | private let info = bundle.infoDictionary! 14 | 15 | internal struct Style { 16 | let actionButtonColor = NSColor(hex: "#2674A8") 17 | let borderColor = NSColor(hex: "#F0F0F0") 18 | let inputBorderColor = NSColor(hex: "#D0D0D0") 19 | let buttonBackgroundColor = NSColor(hex: "#FFFFFF") 20 | let cornerRadius = CGFloat(7.0) 21 | let darkenBackgroundColor = NSColor(hex: "#F4F4F4") 22 | let inputBackgroundColor = NSColor(hex: "#FFFFFF") 23 | let noteColor = NSColor(hex: "#555555") 24 | let primaryBackgroundColor = NSColor(hex: "#FFFFFF") 25 | let selectedBackgroundColor = NSColor(hex: "22BDFC") 26 | let primaryFontColor = NSColor(hex: "#474747") 27 | } 28 | 29 | internal struct Icon { 30 | let bookmarkOff: NSImage = NSImage(imageLiteralResourceName: "bookmark-off") 31 | let bookmarkOn: NSImage = NSImage(imageLiteralResourceName: "bookmark-on") 32 | } 33 | 34 | internal struct IconName { 35 | let ascending = "ascending" 36 | let descending = "descending" 37 | } 38 | 39 | internal struct ChapterPageSize { 40 | let width = 730 41 | let height = 1040 42 | let minimumZoomScale = 0.1 43 | let zoomScaleStep = 0.1 44 | } 45 | 46 | public struct Config { 47 | static let YomuAPI: String = info["YomuAPI"] as! String 48 | static let style = Style() 49 | static let icon = Icon() 50 | static let iconName = IconName() 51 | static let chapterPageSize = ChapterPageSize() 52 | static let scrollOffsetPerEvent = 100.0 53 | 54 | static fileprivate var iconByName: [String: NSImage] = [:] 55 | 56 | static func icon(name: String) -> NSImage { 57 | if let icon = iconByName[name] { 58 | return icon 59 | } 60 | 61 | let _icon = bundle.image(forResource: NSImage.Name(rawValue: name))! 62 | iconByName[name] = _icon 63 | 64 | return _icon 65 | } 66 | 67 | enum KeyboardEvent: String { 68 | case nextChapter = "l" 69 | case previousChapter = "h" 70 | case scrollDown = "j" 71 | case scrollUp = "k" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Yomu/Common/Utils/Database.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Database.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | struct Database { 13 | static fileprivate let version: UInt64 = 1 14 | static fileprivate var _realm: Realm! 15 | 16 | static func realm() -> Realm { 17 | guard _realm != nil else { 18 | Database.migrate() 19 | 20 | _realm = try! Realm() 21 | 22 | return _realm 23 | } 24 | 25 | return _realm 26 | } 27 | 28 | static func migrate() { 29 | let config = Realm.Configuration( 30 | schemaVersion: Database.version, 31 | 32 | // This block will be called automatically when opening a Realm with 33 | // a schema version lower than the one set above 34 | migrationBlock: { migration, _ in 35 | var maxIndex = -1 36 | 37 | // The enumerateObjects(ofType:_:) method iterates 38 | // over every Person object stored in the Realm file 39 | migration.enumerateObjects(ofType: MangaRealm.className()) { _, _ in 40 | maxIndex = maxIndex + 1 41 | } 42 | 43 | // The enumerateObjects(ofType:_:) method iterates 44 | // over every Person object stored in the Realm file 45 | migration.enumerateObjects(ofType: MangaRealm.className()) { _, newObject in 46 | newObject!["position"] = maxIndex 47 | maxIndex = maxIndex - 1 48 | } 49 | } 50 | ) 51 | 52 | // Tell Realm to use this new configuration object for the default Realm 53 | Realm.Configuration.defaultConfiguration = config 54 | } 55 | 56 | static func queryMangas() -> Array { 57 | return realm() 58 | .objects(MangaRealm.self) 59 | .map(MangaRealm.from(mangaRealm:)) 60 | } 61 | 62 | static func queryManga(id: String) -> Manga { 63 | let mangaRealm: MangaRealm = queryMangaRealm(id: id) 64 | 65 | return MangaRealm.from(mangaRealm: mangaRealm) 66 | } 67 | 68 | static func queryMangaRealm(id: String) -> MangaRealm { 69 | return realm().object(ofType: MangaRealm.self, forPrimaryKey: id)! 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Yomu/Common/Utils/DisposeBag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DisposeBag.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxSwift 10 | import Swiftz 11 | 12 | precedencegroup YomuAddToDisposeBagPrecedence { 13 | associativity: left 14 | lowerThan: MonadPrecedenceLeft 15 | higherThan: AssignmentPrecedence 16 | } 17 | 18 | infix operator ==> : YomuAddToDisposeBagPrecedence 19 | 20 | func ==> (disposable: Disposable, disposeBag: DisposeBag) { 21 | disposable.disposed(by: disposeBag) 22 | } 23 | 24 | infix operator ~>> : YomuAddToDisposeBagPrecedence 25 | 26 | func ~>> (disposable: Disposable?, disposeBag: DisposeBag) { 27 | disposable >>- { $0 ==> disposeBag } 28 | } 29 | -------------------------------------------------------------------------------- /Yomu/Common/Utils/Function.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Function.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 10/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func void(arg: T) { 12 | return Void() 13 | } 14 | -------------------------------------------------------------------------------- /Yomu/Common/Utils/Router.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Router.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Swiftz 11 | 12 | protocol RouteId { 13 | var name: String { get } 14 | } 15 | 16 | protocol Route { 17 | var id: RouteId { get } 18 | var views: [NSView] { get } 19 | } 20 | 21 | struct Router { 22 | fileprivate static var routes = List() 23 | 24 | static func register(route: Route) { 25 | routes = List.cons(head: route, tail: routes) 26 | } 27 | 28 | static func moveTo(id: RouteId) { 29 | routes.forEach { 30 | if $0.id.name == id.name { 31 | show(route: $0) 32 | } else { 33 | hide(route: $0) 34 | } 35 | } 36 | } 37 | 38 | fileprivate static func show(route: Route) { 39 | route.views.forEach(show) 40 | } 41 | 42 | fileprivate static func hide(route: Route) { 43 | route.views.forEach(hide) 44 | } 45 | 46 | fileprivate static func hide(view: NSView) { 47 | view.isHidden = true 48 | } 49 | 50 | fileprivate static func show(view: NSView) { 51 | view.isHidden = false 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Yomu/Common/Views/ActionButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActionButton.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/5/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | class ActionButton: NSButton { 12 | override func viewDidMoveToWindow() { 13 | super.viewDidMoveToWindow() 14 | 15 | let paragraphStyle = NSMutableParagraphStyle() 16 | paragraphStyle.alignment = .center 17 | 18 | attributedTitle = NSAttributedString(string: title, attributes: [ 19 | NSAttributedStringKey.foregroundColor: Config.style.actionButtonColor, 20 | NSAttributedStringKey.paragraphStyle: paragraphStyle, 21 | NSAttributedStringKey.font: NSFont.systemFont(ofSize: 13, weight: NSFont.Weight.thin) 22 | ]) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Yomu/Common/Views/MenuableCollectionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuableCollectionView.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 10/2/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol CollectionViewMenuSource: class { 12 | func menu(for event: NSEvent) -> NSMenu? 13 | } 14 | 15 | extension CollectionViewMenuSource { 16 | func menu(for event: NSEvent) -> NSMenu? { 17 | return nil 18 | } 19 | } 20 | 21 | class MenuableCollectionView: NSCollectionView { 22 | weak var menuSource: CollectionViewMenuSource? 23 | 24 | override func menu(for event: NSEvent) -> NSMenu? { 25 | return menuSource?.menu(for: event) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Yomu/Common/Views/NoteLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NoteLabel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 9/2/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class NoteLabel: NSTextField { 12 | override func viewWillDraw() { 13 | super.viewWillDraw() 14 | 15 | textColor = Config.style.noteColor 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Yomu/Common/Views/SearchTextInput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextInput.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/9/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | class SearchTextInput: NSSearchField { 12 | override func viewDidMoveToWindow() { 13 | textColor = Config.style.primaryFontColor 14 | focusRingType = NSFocusRingType.none 15 | 16 | // Use new layer with background to remove border 17 | // http://stackoverflow.com/questions/38921355/osx-cocoa-nssearchfield-clear-button-not-responding-to-click 18 | let maskLayer = CALayer() 19 | maskLayer.backgroundColor = Config.style.inputBackgroundColor.cgColor 20 | 21 | wantsLayer = true 22 | layer = maskLayer 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Yomu/Common/Views/StickyHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StickyHeader.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/31/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class StickyHeader: NSView { 12 | override func viewWillDraw() { 13 | super.viewWillDraw() 14 | 15 | let border = Border(position: .bottom, width: 1.0, color: Config.style.borderColor) 16 | drawBorder(border) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Yomu/Common/Views/TextInput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ZoomScaleTextInput.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 12/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Foundation 11 | 12 | class TextInput: NSTextField { 13 | override func viewDidMoveToWindow() { 14 | super.viewDidMoveToWindow() 15 | 16 | drawBorder(Border(position: .bottom, width: 1.0, color: Config.style.inputBorderColor)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Yomu/Common/Views/TextInputContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextInputContainer.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/11/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | class TextInputContainer: NSBox { 12 | override func viewDidMoveToWindow() { 13 | super.viewDidMoveToWindow() 14 | 15 | cornerRadius = Config.style.cornerRadius 16 | fillColor = Config.style.inputBackgroundColor 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/Chapter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Chapter.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/11/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Curry 11 | import Runes 12 | 13 | /// Example chapter response from Manga Eden API 14 | /// [ 15 | /// 700, // Number 16 | /// 1415346745.0, // Chapter date 17 | /// "Uzumaki Naruto!!", // Title 18 | /// "545c7a3945b9ef92f1e256f7" // ID 19 | /// ], 20 | private struct ChapterJSONMapping { 21 | static let id = 3 22 | static let number = 0 23 | static let title = 2 24 | } 25 | 26 | struct Chapter { 27 | let id: String 28 | let number: Int 29 | let title: String 30 | } 31 | 32 | extension Chapter: Argo.Decodable { 33 | static func decode(_ json: JSON) -> Decoded { 34 | switch json { 35 | case JSON.array(var jsonStrings): 36 | if case JSON.null = jsonStrings[ChapterJSONMapping.title] { 37 | jsonStrings[ChapterJSONMapping.title] = JSON.string("") 38 | } 39 | 40 | return curry(Chapter.init) 41 | <^> String.decode(jsonStrings[ChapterJSONMapping.id]) 42 | <*> Int.decode(jsonStrings[ChapterJSONMapping.number]) 43 | <*> String.decode(jsonStrings[ChapterJSONMapping.title]) 44 | 45 | default: 46 | return .typeMismatch(expected: "Array of JSON String", actual: json) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterCollection.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 115 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterCollectionViewController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Kingfisher 11 | import RxSwift 12 | import RxCocoa 13 | import Swiftz 14 | 15 | protocol ChapterSelectionDelegate: class { 16 | func chapterDidSelected(_ chapter: Chapter, navigator: ChapterNavigator) 17 | } 18 | 19 | class ChapterCollectionViewController: NSViewController { 20 | @IBOutlet weak var collectionView: NSCollectionView! 21 | @IBOutlet weak var progressIndicator: NSProgressIndicator! 22 | @IBOutlet weak var chapterTitle: NSTextField! 23 | @IBOutlet weak var toggleSort: NSButton! 24 | 25 | weak var chapterSelectionDelegate: ChapterSelectionDelegate? 26 | 27 | let viewModel: ChapterCollectionViewModel 28 | var disposeBag = DisposeBag() 29 | 30 | init(viewModel: ChapterCollectionViewModel) { 31 | self.viewModel = viewModel 32 | 33 | super.init(nibName: NSNib.Name(rawValue: "ChapterCollection"), bundle: nil) 34 | } 35 | 36 | required init?(coder: NSCoder) { 37 | fatalError("init(coder:) has not been implemented") 38 | } 39 | 40 | override func viewDidLoad() { 41 | super.viewDidLoad() 42 | 43 | collectionView.delegate = self 44 | collectionView.dataSource = self 45 | setupSubscriptions() 46 | } 47 | 48 | override func viewWillLayout() { 49 | super.viewWillLayout() 50 | collectionView.collectionViewLayout?.invalidateLayout() 51 | } 52 | 53 | func setupSubscriptions() { 54 | // Cleanup everytime we setup subscriptions 55 | disposeBag = DisposeBag() 56 | viewModel.reset() 57 | 58 | viewModel 59 | .reload 60 | .drive(onNext: collectionView.reloadData) ==> disposeBag 61 | 62 | viewModel 63 | .fetching 64 | .drive(onNext: progressIndicator.animating) ==> disposeBag 65 | 66 | chapterTitle 67 | .rx.text.orEmpty 68 | .throttle(0.5, scheduler: MainScheduler.instance) 69 | .bind(to: viewModel.filterPattern) ==> disposeBag 70 | 71 | toggleSort 72 | .rx.tap 73 | .bind(to: viewModel.toggleSort) ==> disposeBag 74 | 75 | viewModel 76 | .orderingIconName 77 | .drive(onNext: { [weak self] in 78 | self?.toggleSort.image = Config.icon(name: $0) 79 | }) ==> disposeBag 80 | } 81 | } 82 | 83 | extension ChapterCollectionViewController: NSCollectionViewDataSource { 84 | func collectionView( 85 | _ collectionView: NSCollectionView, 86 | numberOfItemsInSection section: Int 87 | ) -> Int { 88 | return viewModel.count 89 | } 90 | 91 | @objc(collectionView:didEndDisplayingItem:forRepresentedObjectAtIndexPath:) func collectionView( 92 | _ collectionView: NSCollectionView, 93 | didEndDisplaying item: NSCollectionViewItem, 94 | forRepresentedObjectAt indexPath: IndexPath 95 | ) { 96 | let _item = item as! ChapterItem 97 | 98 | _item.didEndDisplaying() 99 | } 100 | 101 | func collectionView( 102 | _ collectionView: NSCollectionView, 103 | itemForRepresentedObjectAt indexPath: IndexPath 104 | ) -> NSCollectionViewItem { 105 | let cell = collectionView.makeItem( 106 | withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "ChapterItem"), 107 | for: indexPath 108 | ) as! ChapterItem 109 | 110 | cell.setup(withViewModel: viewModel[(indexPath as NSIndexPath).item]) 111 | 112 | return cell 113 | } 114 | } 115 | 116 | extension ChapterCollectionViewController: NSCollectionViewDelegateFlowLayout { 117 | func collectionView( 118 | _ collectionView: NSCollectionView, 119 | didSelectItemsAt indexPaths: Set 120 | ) { 121 | let index = (indexPaths.first! as NSIndexPath).item 122 | let chapterVm = viewModel[index] 123 | let navigator = ChapterNavigator(collection: viewModel, currentIndex: index) 124 | 125 | collectionView.deselectAll(self) 126 | chapterSelectionDelegate?.chapterDidSelected(chapterVm.chapter, navigator: navigator) 127 | } 128 | 129 | func collectionView( 130 | _ collectionView: NSCollectionView, 131 | layout collectionViewLayout: NSCollectionViewLayout, 132 | sizeForItemAt indexPath: IndexPath 133 | ) -> NSSize { 134 | return CGSize(width: collectionView.bounds.size.width, height: 88) 135 | } 136 | } 137 | 138 | extension ChapterCollectionViewController: MangaSelectionDelegate { 139 | func mangaDidSelected(_ manga: Manga) { 140 | setupSubscriptions() 141 | 142 | // Reset filter 143 | chapterTitle.stringValue = "" 144 | 145 | // Scroll to the top everytime manga is selected 146 | if !viewModel.isEmpty { 147 | let index = IndexPath(item: 0, section: 0) 148 | let indexPaths: Set = [index] 149 | collectionView.scrollToItems(at: indexPaths, scrollPosition: .top) 150 | } 151 | 152 | // At this point we are sure that manga.id will 100% available 153 | viewModel.fetch(id: manga.id!) ==> disposeBag 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterCollectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxMoya 10 | import RxSwift 11 | import RxCocoa 12 | import Swiftz 13 | 14 | enum SortOrder { 15 | case ascending 16 | case descending 17 | } 18 | 19 | struct ChapterCollectionViewModel { 20 | // MARK: Public 21 | var count: Int { 22 | return _filteredChapters.value.count 23 | } 24 | 25 | var isEmpty: Bool { 26 | return count == 0 27 | } 28 | 29 | var ordering: SortOrder { 30 | return _ordering.value 31 | } 32 | 33 | // MARK: Input 34 | let filterPattern = PublishSubject() 35 | let toggleSort = PublishSubject() 36 | 37 | // MARK: Output 38 | let reload: Driver 39 | let fetching: Driver 40 | let disposeBag = DisposeBag() 41 | let orderingIconName: Driver 42 | 43 | // MARK: Private 44 | fileprivate let _chapters = Variable(List()) 45 | fileprivate let _filteredChapters = Variable(List()) 46 | fileprivate let _fetching = Variable(false) 47 | fileprivate let _ordering = Variable(SortOrder.descending) 48 | 49 | init() { 50 | let chapters = self._chapters 51 | let filteredChapters = self._filteredChapters 52 | let _ordering = self._ordering 53 | 54 | // MARK: Fetching chapters 55 | fetching = _fetching.asDriver() 56 | 57 | chapters 58 | .asObservable() 59 | .bind(to: _filteredChapters) ==> disposeBag 60 | 61 | reload = _filteredChapters 62 | .asDriver() 63 | .map(void) 64 | 65 | // MARK: Filtering chapters 66 | filterPattern 67 | .flatMap { pattern -> Observable> in 68 | if pattern.isEmpty { 69 | return chapters.asObservable() 70 | } 71 | 72 | return chapters 73 | .asObservable() 74 | .map { chaptersList in 75 | chaptersList.filter { chapterVM in 76 | chapterVM.chapterNumberMatches(pattern: pattern) 77 | } 78 | } 79 | } 80 | .bind(to: _filteredChapters) ==> disposeBag 81 | 82 | // MARK: Sorting chapters 83 | toggleSort 84 | .map { 85 | _ordering.value == .descending ? .ascending : .descending 86 | } 87 | .bind(to: _ordering) ==> disposeBag 88 | 89 | orderingIconName = _ordering 90 | .asDriver() 91 | .map { 92 | switch $0 { 93 | case .ascending: 94 | return Config.iconName.ascending 95 | case .descending: 96 | return Config.iconName.descending 97 | } 98 | } 99 | 100 | _ordering 101 | .asObservable() 102 | .map { order in 103 | // We cannot use (>) because the (>)'s arguments ordering in 104 | // sort method need to be flipped too, the easiest way is to flip it 105 | order == .ascending ? curry(<) : flip(curry(<)) 106 | } 107 | .map { (compare: @escaping (Int) -> (Int) -> Bool) in 108 | let sorted = filteredChapters.value.sorted { 109 | compare($0.chapter.number)($1.chapter.number) 110 | } 111 | 112 | return List(fromArray: sorted) 113 | } 114 | .bind(to: _filteredChapters) ==> disposeBag 115 | } 116 | 117 | func fetch(id: String) -> Disposable { 118 | let api = MangaEdenAPI.mangaDetail(id) 119 | let request = MangaEden.request(api) 120 | 121 | let fetchingDisposable = request 122 | .map(const(false)) 123 | .asDriver(onErrorJustReturn: false) 124 | .startWith(true) 125 | .drive(_fetching) 126 | 127 | let resultDisposable = request 128 | .filterSuccessfulStatusCodes() 129 | .mapArray(Chapter.self, withRootKey: "chapters") 130 | .asDriver(onErrorJustReturn: []) 131 | .map { 132 | $0.map(ChapterViewModel.init) 133 | } 134 | .map(List.init) 135 | .drive(_chapters) 136 | 137 | return CompositeDisposable(fetchingDisposable, resultDisposable) 138 | } 139 | 140 | func reset() { 141 | _filteredChapters.value = List() 142 | _chapters.value = List() 143 | _ordering.value = .descending 144 | filterPattern.onNext("") 145 | } 146 | 147 | subscript(index: Int) -> ChapterViewModel { 148 | return _filteredChapters.value[UInt(index)] 149 | } 150 | } 151 | 152 | struct ChapterNavigator { 153 | let collection: ChapterCollectionViewModel 154 | var currentIndex: Int 155 | 156 | init(collection: ChapterCollectionViewModel, currentIndex: Int) { 157 | self.collection = collection 158 | self.currentIndex = currentIndex 159 | } 160 | 161 | func previous() -> (ChapterNavigator, ChapterViewModel)? { 162 | if collection.ordering == .descending { 163 | guard let viewModel = peekNext() else { 164 | return nil 165 | } 166 | 167 | return ( 168 | ChapterNavigator(collection: collection, currentIndex: currentIndex + 1), 169 | viewModel 170 | ) 171 | } else { 172 | guard let viewModel = peekPrevious() else { 173 | return nil 174 | } 175 | 176 | return ( 177 | ChapterNavigator(collection: collection, currentIndex: currentIndex - 1), 178 | viewModel 179 | ) 180 | } 181 | } 182 | 183 | func next() -> (ChapterNavigator, ChapterViewModel)? { 184 | if collection.ordering == .descending { 185 | guard let viewModel = peekPrevious() else { 186 | return nil 187 | } 188 | 189 | return ( 190 | ChapterNavigator(collection: collection, currentIndex: currentIndex - 1), 191 | viewModel 192 | ) 193 | } else { 194 | guard let viewModel = peekNext() else { 195 | return nil 196 | } 197 | 198 | return ( 199 | ChapterNavigator(collection: collection, currentIndex: currentIndex + 1), 200 | viewModel 201 | ) 202 | } 203 | } 204 | 205 | func peekPrevious() -> ChapterViewModel? { 206 | let previousIndex = currentIndex - 1 207 | 208 | guard previousIndex > -1 else { 209 | return nil 210 | } 211 | 212 | return collection[previousIndex] 213 | } 214 | 215 | func peekNext() -> ChapterViewModel? { 216 | let nextIndex = currentIndex + 1 217 | 218 | guard nextIndex < collection.count else { 219 | return nil 220 | } 221 | 222 | return collection[nextIndex] 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaChapterCollectionViewItem.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxSwift 11 | 12 | class ChapterItem: NSCollectionViewItem { 13 | @IBOutlet weak var chapterTitle: NSTextField! 14 | @IBOutlet weak var chapterNumber: NSTextField! 15 | @IBOutlet weak var chapterPreview: NSImageView! 16 | 17 | var disposeBag = DisposeBag() 18 | 19 | override func viewDidLoad() { 20 | super.viewDidLoad() 21 | 22 | chapterPreview.kf.indicatorType = .activity 23 | } 24 | 25 | func didEndDisplaying() { 26 | chapterPreview.image = .none 27 | 28 | disposeBag = DisposeBag() 29 | } 30 | 31 | func setup(withViewModel viewModel: ChapterViewModel) { 32 | disposeBag = DisposeBag() 33 | 34 | // Show activity indicator right now because fetch preview will 35 | // fetch chapter pages first, after the pages are loaded, the first image of the pages 36 | // will be fetched. Activity indicator will be removed automatically by kingfisher 37 | // after image preview is fetched. 38 | chapterPreview.kf.indicator?.startAnimatingView() 39 | 40 | viewModel 41 | .fetchPreview() ==> disposeBag 42 | 43 | viewModel.title 44 | .drive(chapterTitle.rx.text.orEmpty) ==> disposeBag 45 | 46 | viewModel.title 47 | .drive(chapterTitle.rx.text.orEmpty) ==> disposeBag 48 | 49 | viewModel 50 | .previewUrl 51 | .drive(onNext: { [weak self] url in 52 | self?.chapterPreview.setImageWithUrl(url) 53 | self?.chapterPreview.kf.indicator?.stopAnimatingView() 54 | }) ==> disposeBag 55 | 56 | viewModel.number 57 | .drive(chapterNumber.rx.text.orEmpty) ==> disposeBag 58 | } 59 | 60 | override func viewWillLayout() { 61 | let border = Border(position: .bottom, width: 1.0, color: Config.style.borderColor) 62 | 63 | view.drawBorder(border) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterList/ChapterViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxMoya 11 | import RxSwift 12 | 13 | struct ChapterViewModel { 14 | // MARK: Public 15 | var chapter: Chapter { 16 | return _chapter.value 17 | } 18 | 19 | // MARK: Output 20 | let previewUrl: Driver 21 | let title: Driver 22 | let number: Driver 23 | 24 | // MARK: Private 25 | private let _chapter: Variable 26 | private let _previewUrl = Variable(ImageUrl(endpoint: "")) 27 | 28 | init(chapter: Chapter) { 29 | _chapter = Variable(chapter) 30 | number = _chapter 31 | .asDriver() 32 | .map { "Chapter \($0.number.description)" } 33 | 34 | title = _chapter 35 | .asDriver() 36 | .map { $0.title } 37 | 38 | previewUrl = _previewUrl 39 | .asDriver() 40 | .filter { $0.endpoint.count != 0 } 41 | .map { $0.url } 42 | } 43 | 44 | func chapterNumberMatches(pattern: String) -> Bool { 45 | return _chapter.value.number.description.lowercased().contains(pattern) 46 | } 47 | 48 | func fetchPreview() -> Disposable { 49 | let id = _chapter.value.id 50 | 51 | return MangaEden 52 | .request(MangaEdenAPI.chapterPages(id)) 53 | .mapArray(ChapterPage.self, withRootKey: "images") 54 | .asDriver(onErrorJustReturn: []) 55 | .filter { $0.count > 0 } // On some rare cases manga eden server return http 503 status code 56 | .map { chapterPages in 57 | return chapterPages 58 | .sorted { 59 | return $0.number < $1.number 60 | } 61 | .first! 62 | .image 63 | } 64 | .drive(_previewUrl) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPage.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/20/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Curry 11 | import Runes 12 | 13 | /// Example chapter detail response 14 | /// [ 15 | /// 27, // Page number 16 | /// "28/28dc5e693e46949930db46693fc828f83024fd9239e815fbbadfac2c.jpg", // Image url 17 | /// 730, // Width 18 | /// 1212 // Height 19 | /// ] 20 | private struct ChapterPageJSONMapping { 21 | static let number = 0 22 | static let image = 1 23 | static let width = 2 24 | static let height = 3 25 | } 26 | 27 | struct ChapterPage { 28 | let number: Int 29 | let image: ImageUrl 30 | let width: Int 31 | let height: Int 32 | } 33 | 34 | extension ChapterPage: Argo.Decodable { 35 | static func decode(_ json: JSON) -> Decoded { 36 | switch json { 37 | case .array(let details): 38 | return curry(ChapterPage.init) 39 | <^> Int.decode(details[ChapterPageJSONMapping.number]) 40 | <*> ImageUrl.decode(details[ChapterPageJSONMapping.image]) 41 | <*> Int.decode(details[ChapterPageJSONMapping.width]) 42 | <*> Int.decode(details[ChapterPageJSONMapping.height]) 43 | default: 44 | return .typeMismatch(expected: "Array of json data", actual: json) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPageCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageCollectionViewController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/20/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxSwift 11 | import Swiftz 12 | 13 | protocol ChapterPageCollectionViewDelegate: class { 14 | func closeChapterPage() 15 | } 16 | 17 | class ChapterPageCollectionViewController: NSViewController { 18 | @IBOutlet weak var collectionView: NSCollectionView! 19 | @IBOutlet weak var close: NSButton! 20 | @IBOutlet weak var readingProgress: NSTextField! 21 | @IBOutlet weak var pageCount: NSTextField! 22 | @IBOutlet weak var zoomIn: NSButton! 23 | @IBOutlet weak var zoomOut: NSButton! 24 | @IBOutlet weak var zoomScale: NSTextField! 25 | @IBOutlet weak var headerTitle: NSTextField! 26 | @IBOutlet weak var nextChapterButton: NSButton! 27 | @IBOutlet weak var previousChapterButton: NSButton! 28 | @IBOutlet weak var chapterTitle: NSTextField! 29 | 30 | weak var delegate: ChapterPageCollectionViewDelegate? 31 | weak var chapterSelectionDelegate: ChapterSelectionDelegate? 32 | 33 | var viewModel: ChapterPageCollectionViewModel 34 | var navigator: ChapterNavigator 35 | var disposeBag = DisposeBag() 36 | var keyDownEventMonitor: Any! = nil 37 | 38 | init(viewModel: ChapterPageCollectionViewModel, navigator: ChapterNavigator) { 39 | self.viewModel = viewModel 40 | self.navigator = navigator 41 | 42 | super.init(nibName: NSNib.Name(rawValue: "ChapterPageCollection"), bundle: nil) 43 | } 44 | 45 | required init?(coder: NSCoder) { 46 | fatalError("init(coder:) has not been implemented") 47 | } 48 | 49 | override func viewWillLayout() { 50 | super.viewWillLayout() 51 | collectionView.collectionViewLayout?.invalidateLayout() 52 | } 53 | 54 | override func viewDidLoad() { 55 | super.viewDidLoad() 56 | 57 | collectionView.dataSource = self 58 | collectionView.delegate = self 59 | setupSubscriptions() 60 | 61 | // We need to return nill in the event handler to turn off "DOONG" sound when 62 | // keyboard is pressed (used for navigation when reading chapter) 63 | keyDownEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in 64 | self?.keyDown(with: event) 65 | 66 | return nil 67 | } 68 | } 69 | 70 | override func viewWillDisappear() { 71 | NSEvent.removeMonitor(keyDownEventMonitor) 72 | } 73 | 74 | func setupSubscriptions() { 75 | disposeBag = DisposeBag() 76 | 77 | zoomIn 78 | .rx.tap 79 | .bind(to: viewModel.zoomIn) ==> disposeBag 80 | 81 | zoomOut 82 | .rx.tap 83 | .bind(to: viewModel.zoomOut) ==> disposeBag 84 | 85 | close 86 | .rx.tap 87 | .subscribe(onNext: { [weak self] in 88 | self?.delegate?.closeChapterPage() 89 | }) ==> disposeBag 90 | 91 | readingProgress 92 | .rx.controlEvent 93 | .map { [weak self] in 94 | Int(self!.readingProgress.stringValue) ?? -1 95 | } 96 | .map { $0 - 1 } 97 | .filter(viewModel.chapterIndexIsValid) 98 | .subscribe(onNext: scrollToChapter) ==> disposeBag 99 | 100 | nextChapterButton 101 | .rx.tap 102 | .subscribe(onNext: moveToNextChapter) ==> disposeBag 103 | 104 | previousChapterButton 105 | .rx.tap 106 | .subscribe(onNext: moveToPreviousChapter) ==> disposeBag 107 | 108 | viewModel 109 | .reload 110 | .drive(onNext: collectionView.reloadData) ==> disposeBag 111 | 112 | viewModel 113 | .invalidateLayout 114 | .drive(onNext: collectionView.collectionViewLayout!.invalidateLayout) ==> disposeBag 115 | 116 | viewModel 117 | .readingProgress 118 | .drive(readingProgress.rx.text.orEmpty) ==> disposeBag 119 | 120 | viewModel 121 | .pageCount 122 | .drive(pageCount.rx.text.orEmpty) ==> disposeBag 123 | 124 | viewModel 125 | .zoomScale 126 | .asDriver(onErrorJustReturn: "") 127 | .drive(zoomScale.rx.text.orEmpty) ==> disposeBag 128 | 129 | viewModel 130 | .headerTitle 131 | .drive(headerTitle.rx.text.orEmpty) ==> disposeBag 132 | 133 | viewModel 134 | .chapterTitle 135 | .drive(chapterTitle.rx.text.orEmpty) ==> disposeBag 136 | 137 | viewModel 138 | .zoomScroll 139 | .drive(onNext: scroll) ==> disposeBag 140 | 141 | viewModel.fetch() ==> disposeBag 142 | 143 | zoomScale 144 | .rx.controlEvent 145 | .map { [weak self] in 146 | self!.zoomScale.stringValue 147 | } 148 | .bind(onNext: viewModel.setZoomScale) ==> disposeBag 149 | } 150 | 151 | func scroll(offset: ScrollOffset) { 152 | let targetRect = collectionView.visibleRect.offsetBy(dx: 0, dy: offset.deltaY) 153 | 154 | collectionView.scrollToVisible(targetRect) 155 | } 156 | 157 | func scrollToChapter(atIndex index: Int) { 158 | viewModel.setCurrentPageIndex(index) 159 | 160 | let set: Set = [IndexPath(item: index, section: 0)] 161 | collectionView.scrollToItems(at: set, scrollPosition: NSCollectionView.ScrollPosition.top) 162 | } 163 | 164 | func moveToPreviousChapter() { 165 | navigator.previous() >>- { [weak self] (navigator, previousChapterVM) in 166 | self?.chapterSelectionDelegate?.chapterDidSelected(previousChapterVM.chapter, navigator: navigator) 167 | } 168 | } 169 | 170 | func moveToNextChapter() { 171 | navigator.next() >>- { [weak self] (navigator, nextChapterVM) in 172 | self?.chapterSelectionDelegate?.chapterDidSelected(nextChapterVM.chapter, navigator: navigator) 173 | } 174 | } 175 | } 176 | 177 | extension ChapterPageCollectionViewController: NSCollectionViewDataSource { 178 | func numberOfSections(in collectionView: NSCollectionView) -> Int { 179 | return 1 180 | } 181 | 182 | func collectionView( 183 | _ collectionView: NSCollectionView, 184 | numberOfItemsInSection section: Int 185 | ) -> Int { 186 | return viewModel.count 187 | } 188 | 189 | func collectionView( 190 | _ collectionView: NSCollectionView, 191 | itemForRepresentedObjectAt indexPath: IndexPath 192 | ) -> NSCollectionViewItem { 193 | let cell = collectionView.makeItem( 194 | withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "ChapterPageItem"), 195 | for: indexPath 196 | ) as! ChapterPageItem 197 | 198 | cell.setup(withViewModel: viewModel[(indexPath as NSIndexPath).item]) 199 | 200 | return cell 201 | } 202 | } 203 | 204 | extension ChapterPageCollectionViewController: NSCollectionViewDelegateFlowLayout { 205 | func collectionView( 206 | _ collectionView: NSCollectionView, 207 | willDisplay item: NSCollectionViewItem, 208 | forRepresentedObjectAt indexPath: IndexPath 209 | ) { 210 | viewModel.setCurrentPageIndex((indexPath as NSIndexPath).item) 211 | } 212 | 213 | func collectionView( 214 | _ collectionView: NSCollectionView, 215 | layout collectionViewLayout: NSCollectionViewLayout, 216 | sizeForItemAt indexPath: IndexPath 217 | ) -> NSSize { 218 | return viewModel.pageSize 219 | } 220 | } 221 | 222 | extension ChapterPageCollectionViewController: ChapterPageContainerDelegate { 223 | 224 | override func keyDown(with event: NSEvent) { 225 | guard 226 | let characters = event.characters, 227 | let key = Config.KeyboardEvent(rawValue: characters) else { 228 | return 229 | } 230 | 231 | switch key { 232 | case Config.KeyboardEvent.nextChapter: 233 | moveToNextChapter() 234 | 235 | case Config.KeyboardEvent.previousChapter: 236 | moveToPreviousChapter() 237 | 238 | case Config.KeyboardEvent.scrollDown: 239 | scrollBy(dx: 0, dy: CGFloat(Config.scrollOffsetPerEvent)) 240 | 241 | case Config.KeyboardEvent.scrollUp: 242 | scrollBy(dx: 0, dy: -CGFloat(Config.scrollOffsetPerEvent)) 243 | } 244 | } 245 | 246 | private func scrollBy(dx: CGFloat, dy: CGFloat) { 247 | let nextRect = collectionView.visibleRect.offsetBy(dx: dx, dy: dy) 248 | let clipView = collectionView.enclosingScrollView!.contentView 249 | clipView.animator().setBoundsOrigin(nextRect.origin) 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPageCollectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPagesViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/26/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxMoya 11 | import RxSwift 12 | import Swiftz 13 | 14 | /// A data structure that represents zoom scale 15 | struct ZoomScale: CustomStringConvertible { 16 | /// Scale in 1 based, 1 -> 100% 17 | let scale: Double 18 | 19 | /// String representation of zoom scale, 20 | /// will automatically multiply the scale by 100 21 | var description: String { 22 | return String(Int(scale * 100)) 23 | } 24 | 25 | /// Normalize the given scale . 26 | /// 27 | /// - parameter scale: Zoom scale, if the scale is greater than 10 then 28 | /// it's considered as 100 based scale (I believe no one wants to zoom in by 1000%) 29 | /// 30 | /// - returns: zoom scale with base 1 (1 -> 100%) 31 | static private func normalize(scale: Double) -> Double { 32 | return scale > 10 ? (scale / 100) : scale 33 | } 34 | 35 | init(scale: Double) { 36 | self.scale = ZoomScale.normalize(scale: scale) 37 | } 38 | 39 | init(scale: String) { 40 | self.init(scale: Double(scale)!) 41 | } 42 | } 43 | 44 | struct PageSizeMargin { 45 | let previousSize: CGSize 46 | let currentSize: CGSize 47 | var margin: CGSize { 48 | return CGSize( 49 | width: currentSize.width - previousSize.width, 50 | height: currentSize.height - previousSize.height 51 | ) 52 | } 53 | } 54 | 55 | struct ScrollOffset { 56 | let marginHeight: CGFloat 57 | let previousItemsCount: Int 58 | 59 | var deltaY: CGFloat { 60 | return marginHeight * CGFloat(previousItemsCount) 61 | } 62 | } 63 | 64 | struct ChapterPageCollectionViewModel { 65 | // MARK: Public 66 | /// Chapter image 67 | var chapterImage: ImageUrl? { 68 | return _chapterPages.value.isEmpty ? .none : _chapterPages.value.first!.image 69 | } 70 | 71 | /// Number of pages in one chapter 72 | var count: Int { 73 | return _chapterPages.value.count 74 | } 75 | 76 | /// Chapter page size based on config 77 | var pageSize: CGSize { 78 | return _pageSize.value 79 | } 80 | 81 | // MARK: Input 82 | let zoomIn = PublishSubject() 83 | let zoomOut = PublishSubject() 84 | 85 | // MARK: Output 86 | let reload: Driver 87 | let chapterPages: Driver> 88 | let invalidateLayout: Driver 89 | let zoomScale: Driver 90 | let headerTitle: Driver 91 | let chapterTitle: Driver 92 | let readingProgress: Driver 93 | let pageCount: Driver 94 | let zoomScroll: Driver 95 | let disposeBag = DisposeBag() 96 | 97 | // MARK: Private 98 | fileprivate let _chapterPages = Variable(List()) 99 | fileprivate let _currentPageIndex = Variable(0) 100 | fileprivate let _pageSize = Variable(CGSize( 101 | width: Config.chapterPageSize.width, 102 | height: Config.chapterPageSize.height 103 | )) 104 | fileprivate let _zoomScale = Variable(ZoomScale(scale: 1.0)) 105 | fileprivate let chapterVM: ChapterViewModel 106 | 107 | init(chapterViewModel: ChapterViewModel) { 108 | let _chapterPages = self._chapterPages 109 | let _zoomScale = self._zoomScale 110 | let _pageSize = self._pageSize 111 | let _currentPageIndex = self._currentPageIndex 112 | 113 | chapterVM = chapterViewModel 114 | chapterPages = _chapterPages.asDriver() 115 | 116 | reload = chapterPages 117 | .asDriver() 118 | .map(void) 119 | 120 | readingProgress = _currentPageIndex 121 | .asDriver() 122 | .map { String($0 + 1) } 123 | 124 | pageCount = _chapterPages 125 | .asDriver() 126 | .map { "/ \($0.count) pages" } 127 | 128 | zoomIn 129 | .map { 130 | ZoomScale(scale: _zoomScale.value.scale + Config.chapterPageSize.zoomScaleStep) 131 | } 132 | .bind(to: _zoomScale) ==> disposeBag 133 | 134 | zoomOut 135 | .filter { 136 | (_zoomScale.value.scale - Config.chapterPageSize.zoomScaleStep) > Config.chapterPageSize.minimumZoomScale 137 | } 138 | .map { 139 | ZoomScale(scale: _zoomScale.value.scale - Config.chapterPageSize.zoomScaleStep) 140 | } 141 | .bind(to: _zoomScale) ==> disposeBag 142 | 143 | _zoomScale 144 | .asObservable() 145 | .map { zoom in 146 | CGSize( 147 | width: Int(Double(Config.chapterPageSize.width) * zoom.scale), 148 | height: Int(Double(Config.chapterPageSize.height) * zoom.scale) 149 | ) 150 | } 151 | .bind(to: _pageSize) ==> disposeBag 152 | 153 | zoomScale = _zoomScale 154 | .asDriver() 155 | .map { $0.description } 156 | 157 | invalidateLayout = _zoomScale 158 | .asDriver() 159 | .map(void) 160 | 161 | let initialMargin = PageSizeMargin(previousSize: CGSize.zero, currentSize: _pageSize.value) 162 | 163 | zoomScroll = _pageSize 164 | .asDriver() 165 | .scan(initialMargin) { previousSizeMargin, nextSize in 166 | PageSizeMargin(previousSize: previousSizeMargin.currentSize, currentSize: nextSize) 167 | } 168 | .map { 169 | ScrollOffset( 170 | marginHeight: CGFloat($0.margin.height), 171 | previousItemsCount: _currentPageIndex.value 172 | ) 173 | } 174 | 175 | headerTitle = chapterVM.number 176 | chapterTitle = chapterVM.title 177 | } 178 | 179 | subscript(index: Int) -> ChapterPageViewModel { 180 | let page = _chapterPages.value[UInt(index)] 181 | 182 | return ChapterPageViewModel(page: page) 183 | } 184 | 185 | func fetch() -> Disposable { 186 | return MangaEden 187 | .request(MangaEdenAPI.chapterPages(chapterVM.chapter.id)) 188 | .mapArray(ChapterPage.self, withRootKey: "images") 189 | .subscribe(onSuccess: { 190 | let sortedPages = $0.sorted { x, y in 191 | return x.number < y.number 192 | } 193 | 194 | self._chapterPages.value = List(fromArray: sortedPages) 195 | }) 196 | } 197 | 198 | func setCurrentPageIndex(_ index: Int) { 199 | _currentPageIndex.value = index 200 | } 201 | 202 | func setZoomScale(_ scale: String) { 203 | _zoomScale.value = ZoomScale(scale: scale) 204 | } 205 | 206 | func chapterIndexIsValid(index: Int) -> Bool { 207 | return 0 ... (count - 1) ~= index 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPageItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageItem.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/20/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxSwift 11 | 12 | class ChapterPageItem: NSCollectionViewItem { 13 | @IBOutlet weak var pageImageView: NSImageView! { 14 | didSet { 15 | pageImageView.kf.indicatorType = .activity 16 | } 17 | } 18 | 19 | var disposeBag = DisposeBag() 20 | 21 | func setup(withViewModel viewModel: ChapterPageViewModel) { 22 | disposeBag = DisposeBag() 23 | 24 | viewModel.imageUrl 25 | .drive(onNext: pageImageView.setImageWithUrl) 26 | .disposed(by: disposeBag) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPageItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Yomu/Screens/ChapterPageList/ChapterPageViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/20/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | struct ChapterPageViewModel { 13 | // MARK: Public 14 | var imageUrl: Driver { 15 | return chapterPage.asDriver().map { $0.image.url } 16 | } 17 | 18 | // MARK: Private 19 | fileprivate let chapterPage: Variable 20 | 21 | init(page: ChapterPage) { 22 | chapterPage = Variable(page) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/Manga.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Manga.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/7/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Argo 11 | import Curry 12 | import Runes 13 | 14 | enum MangaPosition: Int { 15 | case undefined = -1 16 | } 17 | 18 | struct Manga { 19 | // Id is an optional because manga eden API does not return manga id 20 | // when we requested manga detail API 21 | var position: Int 22 | var id: String? 23 | let slug: String 24 | let title: String 25 | let author: String 26 | let image: ImageUrl 27 | var releasedYear: Int? 28 | let description: String 29 | let categories: [String] 30 | 31 | static func copyWith(position: Int, manga: Manga) -> Manga { 32 | return Manga( 33 | position: MangaPosition.undefined.rawValue, 34 | id: manga.id, 35 | slug: manga.slug, 36 | title: manga.title, 37 | author: manga.author, 38 | image: manga.image, 39 | releasedYear: manga.releasedYear, 40 | description: manga.description, 41 | categories: manga.categories 42 | ) 43 | } 44 | } 45 | 46 | extension Manga: Argo.Decodable { 47 | static func decode(_ json: JSON) -> Decoded { 48 | 49 | return curry(Manga.init)(MangaPosition.undefined.rawValue) 50 | <^> json[optional: "i"] 51 | <*> json["alias"] 52 | <*> json["title"] 53 | <*> json["author"] 54 | <*> json["image"] 55 | <*> json["released"] 56 | <*> json["description"] 57 | <*> json["categories"] 58 | } 59 | } 60 | 61 | extension Manga: Hashable { 62 | var hashValue: Int { 63 | return id!.hashValue 64 | } 65 | } 66 | 67 | extension Manga: Equatable { } 68 | 69 | func == (lhs: Manga, rhs: Manga) -> Bool { 70 | return lhs.id == rhs.id 71 | } 72 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaCollection.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 40 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaCollectionViewController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxMoya 11 | import RxSwift 12 | 13 | protocol MangaSelectionDelegate: class { 14 | func mangaDidSelected(_ manga: Manga) 15 | } 16 | 17 | class MangaCollectionViewController: NSViewController { 18 | @IBOutlet weak var mangaCollectionView: MenuableCollectionView! 19 | 20 | weak var mangaSelectionDelegate: MangaSelectionDelegate? 21 | 22 | let viewModel: MangaCollectionViewModel 23 | let disposeBag = DisposeBag() 24 | 25 | var currentlyDraggedIndexPaths = Set() 26 | 27 | init(viewModel: MangaCollectionViewModel) { 28 | self.viewModel = viewModel 29 | 30 | super.init(nibName: NSNib.Name(rawValue: "MangaCollection"), bundle: nil) 31 | } 32 | 33 | required init?(coder: NSCoder) { 34 | fatalError("init(coder:) has not been implemented") 35 | } 36 | 37 | override func viewDidLoad() { 38 | super.viewDidLoad() 39 | 40 | mangaCollectionView.menuSource = self 41 | mangaCollectionView.dataSource = self 42 | mangaCollectionView.delegate = self 43 | mangaCollectionView.registerForDraggedTypes([NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType.string]) 44 | 45 | viewModel 46 | .reload 47 | .drive(onNext: mangaCollectionView.reloadData) ==> disposeBag 48 | } 49 | 50 | override func viewWillLayout() { 51 | super.viewWillLayout() 52 | 53 | mangaCollectionView.collectionViewLayout?.invalidateLayout() 54 | 55 | let border = Border(position: .right, width: 1.0, color: Config.style.darkenBackgroundColor) 56 | view.drawBorder(border) 57 | } 58 | } 59 | 60 | extension MangaCollectionViewController: NSCollectionViewDataSource { 61 | func numberOfSections(in collectionView: NSCollectionView) -> Int { 62 | return 1 63 | } 64 | 65 | func collectionView( 66 | _ collectionView: NSCollectionView, 67 | numberOfItemsInSection section: Int 68 | ) -> Int { 69 | return viewModel.count 70 | } 71 | 72 | func collectionView( 73 | _ collectionView: NSCollectionView, 74 | itemForRepresentedObjectAt indexPath: IndexPath 75 | ) -> NSCollectionViewItem { 76 | let cell = collectionView.makeItem( 77 | withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "MangaItem"), 78 | for: indexPath 79 | ) as! MangaItem 80 | 81 | cell.setup(withViewModel: viewModel[(indexPath as NSIndexPath).item]) 82 | 83 | return cell 84 | } 85 | } 86 | 87 | extension MangaCollectionViewController: NSCollectionViewDelegateFlowLayout { 88 | func collectionView( 89 | _ collectionView: NSCollectionView, 90 | didSelectItemsAt indexPaths: Set 91 | ) { 92 | let index = (indexPaths.first! as NSIndexPath).item 93 | let mangaViewModel = viewModel[index] 94 | 95 | viewModel.setSelectedIndex(index) 96 | mangaSelectionDelegate?.mangaDidSelected(mangaViewModel.manga) 97 | } 98 | 99 | // MARK: Drag and drop operation 100 | // -------------------------------------------------- 101 | 102 | func collectionView( 103 | _ collectionView: NSCollectionView, 104 | pasteboardWriterForItemAt indexPath: IndexPath 105 | ) -> NSPasteboardWriting? { 106 | let item = NSPasteboardItem() 107 | let mangaViewModel = viewModel[indexPath.item] 108 | 109 | // We need to set this value 110 | // to satisfy collectionView(_:validateDrop:proposedIndexPath:dropOperation:) 111 | // https://developer.apple.com/reference/appkit/nscollectionviewdelegate/1525471-collectionview 112 | item.setString(mangaViewModel.id, forType: NSPasteboard.PasteboardType.string) 113 | 114 | return item 115 | } 116 | 117 | func collectionView( 118 | _ collectionView: NSCollectionView, 119 | validateDrop draggingInfo: NSDraggingInfo, 120 | proposedIndexPath proposedDropIndexPath: AutoreleasingUnsafeMutablePointer, 121 | dropOperation proposedDropOperation: UnsafeMutablePointer 122 | ) -> NSDragOperation { 123 | return NSDragOperation.move 124 | } 125 | 126 | func collectionView( 127 | _ collectionView: NSCollectionView, 128 | draggingSession session: NSDraggingSession, 129 | willBeginAt screenPoint: NSPoint, 130 | forItemsAt indexPaths: Set 131 | ) { 132 | currentlyDraggedIndexPaths = indexPaths 133 | } 134 | 135 | func collectionView( 136 | _ collectionView: NSCollectionView, 137 | draggingSession session: NSDraggingSession, 138 | endedAt screenPoint: NSPoint, 139 | dragOperation operation: NSDragOperation 140 | ) { 141 | currentlyDraggedIndexPaths = [] 142 | } 143 | 144 | func collectionView( 145 | _ collectionView: NSCollectionView, 146 | acceptDrop draggingInfo: NSDraggingInfo, 147 | indexPath: IndexPath, 148 | dropOperation: NSCollectionView.DropOperation 149 | ) -> Bool { 150 | let fromIndexPath = currentlyDraggedIndexPaths.first! 151 | 152 | collectionView.animator().moveItem(at: fromIndexPath, to: indexPath) 153 | viewModel.move(fromIndex: fromIndexPath.item, toIndex: indexPath.item) 154 | 155 | return true 156 | } 157 | } 158 | 159 | extension MangaCollectionViewController: CollectionViewMenuSource { 160 | func menu(for event: NSEvent) -> NSMenu? { 161 | let menu = NSMenu() 162 | 163 | let point = mangaCollectionView.convert(event.locationInWindow, from: nil) 164 | 165 | // There's a possibility that user right click on empty space between cell items 166 | guard let indexPath = mangaCollectionView.indexPathForItem(at: point) else { 167 | return .none 168 | } 169 | 170 | let delete = NSMenuItem( 171 | title: "Delete Manga", 172 | action: #selector(MangaCollectionViewController.deleteManga(item:)), 173 | keyEquivalent: "" 174 | ) 175 | delete.representedObject = indexPath 176 | 177 | let showChapters = NSMenuItem( 178 | title: "Show Chapters", 179 | action: #selector(MangaCollectionViewController.showChapters(item:)), 180 | keyEquivalent: "" 181 | ) 182 | showChapters.representedObject = indexPath 183 | 184 | menu.addItem(showChapters) 185 | menu.addItem(delete) 186 | 187 | return menu 188 | } 189 | 190 | @objc func deleteManga(item: NSMenuItem) { 191 | let indexPath = item.representedObject as! IndexPath 192 | 193 | viewModel.remove(mangaIndex: indexPath.item) 194 | } 195 | 196 | @objc func showChapters(item: NSMenuItem) { 197 | let indexPath = item.representedObject as! IndexPath 198 | 199 | collectionView(mangaCollectionView, didSelectItemsAt: Set([indexPath])) 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaCollectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangasViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/3/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import OrderedSet 10 | import class RealmSwift.Realm 11 | import RxCocoa 12 | import RxMoya 13 | import RxSwift 14 | import RxRealm 15 | import Swiftz 16 | 17 | struct SelectedIndex { 18 | let previousIndex: Int 19 | let index: Int 20 | } 21 | 22 | struct MangaCollectionViewModel { 23 | // MARK: Public 24 | var count: Int { 25 | return mangaViewModels.value.count 26 | } 27 | 28 | // MARK: Output 29 | let fetching: Driver 30 | let reload: Driver 31 | let mangas: Driver> 32 | 33 | // MARK: Private 34 | fileprivate let _selectedIndex = Variable(SelectedIndex(previousIndex: -1, index: -1)) 35 | fileprivate var _fetching = Variable(false) 36 | fileprivate var _mangas: Variable> = { 37 | let mangas = Database.queryMangas().sorted { 38 | $0.position < $1.position 39 | } 40 | 41 | return Variable(OrderedSet(elements: mangas)) 42 | }() 43 | fileprivate let recentlyDeletedManga: Variable = Variable(.none) 44 | fileprivate let mangaViewModels = Variable(List()) 45 | fileprivate let disposeBag = DisposeBag() 46 | 47 | init() { 48 | fetching = _fetching.asDriver() 49 | mangas = mangaViewModels.asDriver() 50 | reload = Observable 51 | .of( 52 | _mangas.asObservable().map(void), 53 | mangaViewModels.asObservable().map(void) 54 | ) 55 | .merge() 56 | .asDriver(onErrorJustReturn: Void()) 57 | 58 | recentlyDeletedManga 59 | .asObservable() 60 | .filter { $0 != nil } 61 | .map { manga in 62 | let id = manga?.id 63 | 64 | return Database.queryMangaRealm(id: id!) 65 | } 66 | .subscribe(Realm.rx.delete()) ==> disposeBag 67 | 68 | _mangas 69 | .asObservable() 70 | .map { 71 | let viewModels = $0.compactMap(MangaViewModel.init) 72 | 73 | return List(fromArray: viewModels) 74 | } 75 | .bind(to: mangaViewModels) ==> disposeBag 76 | 77 | _mangas 78 | .asObservable() 79 | .filter { $0.count != 0 } 80 | .flatMap { Observable.from($0) } 81 | .map(MangaRealm.from(manga:)) 82 | .subscribe(Realm.rx.add(update: true)) ==> disposeBag 83 | } 84 | 85 | subscript(index: Int) -> MangaViewModel { 86 | return mangaViewModels.value[UInt(index)] 87 | } 88 | 89 | func fetch(id: String) -> Disposable { 90 | let api = MangaEdenAPI.mangaDetail(id) 91 | 92 | let request = MangaEden.request(api) 93 | 94 | let fetchingDisposable = request 95 | .map(const(false)) 96 | .asDriver(onErrorJustReturn: false) 97 | .startWith(true) 98 | .drive(_fetching) 99 | 100 | let resultDisposable = request 101 | .filterSuccessfulStatusCodes() 102 | .map(Manga.self) 103 | .map { 104 | var manga = $0 105 | manga.id = id 106 | manga.position = self.count 107 | 108 | return manga 109 | } 110 | .filter { 111 | return !self._mangas.value.contains($0) 112 | } 113 | .subscribe(onSuccess: { 114 | self._mangas.value.append(element: $0) 115 | }) 116 | 117 | return CompositeDisposable(fetchingDisposable, resultDisposable) 118 | } 119 | 120 | func setSelectedIndex(_ index: Int) { 121 | let previous = _selectedIndex.value 122 | let selectedIndex = SelectedIndex(previousIndex: previous.index, index: index) 123 | _selectedIndex.value = selectedIndex 124 | 125 | mangaViewModels.value.forEach { 126 | $0.setSelected(false) 127 | } 128 | 129 | self[selectedIndex.index].setSelected(true) 130 | } 131 | 132 | func move(fromIndex: Int, toIndex: Int) { 133 | let manga = self[fromIndex].manga 134 | _mangas.value.remove(index: fromIndex) 135 | _mangas.value.insert(element: manga, atIndex: toIndex) 136 | updateMangaPositions() 137 | } 138 | 139 | func remove(mangaIndex: Int) { 140 | recentlyDeletedManga.value = _mangas.value.remove(index: mangaIndex) 141 | updateMangaPositions() 142 | } 143 | 144 | private func updateMangaPositions() { 145 | let indexes: [Int] = [Int](0.. disposeBag 27 | 28 | viewModel 29 | .previewUrl 30 | .drive(onNext: mangaImageView.setImageWithUrl) ==> disposeBag 31 | 32 | viewModel 33 | .categoriesString 34 | .drive(categoryTextField.rx.text.orEmpty) ==> disposeBag 35 | 36 | viewModel 37 | .selected 38 | .map(!) 39 | .drive(selectedIndicator.rx.isHidden) ==> disposeBag 40 | } 41 | 42 | override func viewDidLoad() { 43 | super.viewDidLoad() 44 | 45 | mangaImageView.kf.indicatorType = .activity 46 | } 47 | 48 | override func viewWillLayout() { 49 | super.viewWillLayout() 50 | 51 | let border = Border(position: .bottom, width: 1.0, color: Config.style.borderColor) 52 | titleContainerView.drawBorder(border) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaRealm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaRealm.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/16/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | /// Represents Manga object for `Realm` database 13 | class MangaRealm: Object { 14 | @objc dynamic var id: String = "" 15 | @objc dynamic var slug: String = "" 16 | @objc dynamic var title: String = "" 17 | @objc dynamic var author: String = "" 18 | @objc dynamic var imageEndpoint: String = "" 19 | @objc dynamic var releasedYear: Int = 0 20 | @objc dynamic var commaSeparatedCategories: String = "" 21 | @objc dynamic var position: Int = MangaPosition.undefined.rawValue 22 | 23 | override static func primaryKey() -> String? { 24 | return "id" 25 | } 26 | 27 | /// Convert the given `Manga` struct to `MangaRealm` object 28 | /// 29 | /// - parameter manga: `Manga` 30 | /// 31 | /// - returns: `MangaRealm` 32 | static func from(manga: Manga) -> MangaRealm { 33 | let mangaRealm = MangaRealm() 34 | 35 | mangaRealm.id = manga.id! 36 | mangaRealm.slug = manga.slug 37 | mangaRealm.title = manga.title 38 | mangaRealm.author = manga.author 39 | mangaRealm.imageEndpoint = manga.image.endpoint 40 | mangaRealm.releasedYear = manga.releasedYear ?? 0 41 | mangaRealm.commaSeparatedCategories = manga.categories.joined(separator: ",") 42 | mangaRealm.position = manga.position 43 | 44 | return mangaRealm 45 | } 46 | 47 | /// Convert the given `MangaRealm` object to `Manga` struct 48 | /// 49 | /// - parameter mangaRealm: `MangaRealm` 50 | /// 51 | /// - returns: `Manga` 52 | static func from(mangaRealm: MangaRealm) -> Manga { 53 | let categories = mangaRealm 54 | .commaSeparatedCategories 55 | .split { 56 | $0 == "," 57 | } 58 | .map(String.init) 59 | 60 | return Manga( 61 | position: mangaRealm.position, 62 | id: mangaRealm.id, 63 | slug: mangaRealm.slug, 64 | title: mangaRealm.title, 65 | author: mangaRealm.author, 66 | image: ImageUrl(endpoint: mangaRealm.imageEndpoint), 67 | releasedYear: mangaRealm.releasedYear, 68 | description: "", 69 | categories: categories 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Yomu/Screens/MangaList/MangaViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | struct MangaViewModel { 13 | // MARK: Public 14 | var id: String { 15 | return _manga.value.id! 16 | } 17 | 18 | var manga: Manga { 19 | return _manga.value 20 | } 21 | 22 | // MARK: Output 23 | let previewUrl: Driver 24 | let title: Driver 25 | let categoriesString: Driver 26 | let selected: Driver 27 | 28 | // MARK: Private 29 | fileprivate let _manga: Variable 30 | fileprivate let _selected = Variable(false) 31 | 32 | init(manga: Manga) { 33 | _manga = Variable(manga) 34 | 35 | previewUrl = _manga 36 | .asDriver() 37 | .map { $0.image.url } 38 | 39 | title = _manga 40 | .asDriver() 41 | .map { $0.title } 42 | 43 | categoriesString = _manga 44 | .asDriver() 45 | .map { 46 | $0.categories.joined(separator: ", ") 47 | } 48 | 49 | selected = _selected.asDriver() 50 | } 51 | 52 | func setSelected(_ selected: Bool) { 53 | _selected.value = selected 54 | } 55 | 56 | func update(manga: Manga) { 57 | self._manga.value = manga 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Yomu/Screens/Root/ChapterPageContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageCollectionView.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 5/3/17. 6 | // Copyright © 2017 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | protocol ChapterPageContainerDelegate: class { 12 | func keyDown(with event: NSEvent) 13 | } 14 | 15 | class ChapterPageContainer: NSView { 16 | weak var delegate: ChapterPageContainerDelegate? 17 | 18 | override var acceptsFirstResponder: Bool { 19 | return true 20 | } 21 | 22 | override func keyDown(with event: NSEvent) { 23 | super.keyDown(with: event) 24 | delegate?.keyDown(with: event) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Yomu/Screens/Root/MangaContainerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaContainerSplitViewController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/3/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import Cartography 11 | import RxSwift 12 | 13 | class MangaContainerViewController: NSViewController { 14 | @IBOutlet weak var mangaContainerView: NSView! 15 | @IBOutlet weak var chapterContainerView: NSView! 16 | @IBOutlet weak var chapterPageContainerView: ChapterPageContainer! 17 | @IBOutlet weak var searchMangaButtonContainer: NSView! 18 | @IBOutlet weak var searchMangaContainerView: NSView! 19 | 20 | let mangaCollectionVM = MangaCollectionViewModel() 21 | var mangaCollectionVC: MangaCollectionViewController! 22 | 23 | let chapterCollectionVM = ChapterCollectionViewModel() 24 | var chapterCollectionVC: ChapterCollectionViewController! 25 | 26 | var chapterPageCollectionVC: ChapterPageCollectionViewController? 27 | 28 | let searchedMangaCollectionVM = SearchedMangaCollectionViewModel() 29 | var searchedMangaVC: SearchedMangaCollectionViewController! 30 | 31 | let disposeBag = DisposeBag() 32 | 33 | override func viewDidLoad() { 34 | super.viewDidLoad() 35 | 36 | mangaCollectionVC = MangaCollectionViewController(viewModel: mangaCollectionVM) 37 | chapterCollectionVC = ChapterCollectionViewController(viewModel: chapterCollectionVM) 38 | searchedMangaVC = SearchedMangaCollectionViewController(viewModel: searchedMangaCollectionVM) 39 | 40 | mangaContainerView.addSubview(mangaCollectionVC.view) 41 | chapterContainerView.addSubview(chapterCollectionVC.view) 42 | searchMangaContainerView.addSubview(searchedMangaVC.view) 43 | 44 | mangaCollectionVC.mangaSelectionDelegate = chapterCollectionVC 45 | chapterCollectionVC.chapterSelectionDelegate = self 46 | searchedMangaVC.delegate = self 47 | 48 | setupRoutes() 49 | setupConstraints() 50 | } 51 | 52 | override func viewWillLayout() { 53 | let border = Border(position: .right, width: 1.0, color: Config.style.darkenBackgroundColor) 54 | searchMangaButtonContainer.drawBorder(border) 55 | } 56 | 57 | func setupRoutes() { 58 | Router.register(route: YomuRoute.main([ 59 | mangaContainerView, 60 | chapterContainerView, 61 | searchMangaButtonContainer 62 | ])) 63 | 64 | Router.register(route: YomuRoute.searchManga([ 65 | searchMangaContainerView 66 | ])) 67 | 68 | Router.register(route: YomuRoute.chapterPage([ 69 | chapterPageContainerView 70 | ])) 71 | } 72 | 73 | func setupConstraints() { 74 | constrain(mangaCollectionVC.view, mangaContainerView) { child, parent in 75 | child.top == parent.top 76 | child.bottom == parent.bottom 77 | child.trailing == parent.trailing 78 | 79 | child.width >= 300 80 | child.height >= 300 81 | } 82 | 83 | constrain(searchedMangaVC.view, searchMangaContainerView) { child, parent in 84 | child.top == parent.top 85 | child.bottom == parent.bottom 86 | child.trailing == parent.trailing 87 | child.leading == parent.leading 88 | } 89 | 90 | constrain(chapterCollectionVC.view, chapterContainerView) { child, parent in 91 | child.top == parent.top 92 | child.bottom == parent.bottom 93 | child.trailing == parent.trailing 94 | child.leading == parent.leading 95 | 96 | child.width >= 470 97 | child.height >= 300 98 | } 99 | } 100 | 101 | @IBAction func showSearchMangaView(_ sender: NSButton) { 102 | Router.moveTo(id: YomuRouteId.SearchManga) 103 | } 104 | } 105 | 106 | extension MangaContainerViewController: ChapterSelectionDelegate { 107 | func chapterDidSelected(_ chapter: Chapter, navigator: ChapterNavigator) { 108 | if chapterPageCollectionVC != nil { 109 | chapterPageCollectionVC!.view.removeFromSuperview() 110 | } 111 | 112 | let pageVM = ChapterPageCollectionViewModel(chapterViewModel: ChapterViewModel(chapter: chapter)) 113 | chapterPageCollectionVC = ChapterPageCollectionViewController(viewModel: pageVM, navigator: navigator) 114 | chapterPageCollectionVC!.delegate = self 115 | chapterPageCollectionVC!.chapterSelectionDelegate = self 116 | chapterPageContainerView.addSubview(chapterPageCollectionVC!.view) 117 | chapterPageContainerView.delegate = chapterPageCollectionVC 118 | 119 | setupChapterPageCollectionConstraints() 120 | Router.moveTo(id: YomuRouteId.ChapterPage) 121 | } 122 | 123 | func setupChapterPageCollectionConstraints() { 124 | constrain(chapterPageCollectionVC!.view, chapterPageContainerView) { child, parent in 125 | child.top == parent.top 126 | child.bottom == parent.bottom 127 | child.left == parent.left 128 | child.right == parent.right 129 | } 130 | } 131 | } 132 | 133 | extension MangaContainerViewController: ChapterPageCollectionViewDelegate { 134 | func closeChapterPage() { 135 | Router.moveTo(id: YomuRouteId.Main) 136 | } 137 | } 138 | 139 | extension MangaContainerViewController: SearchedMangaDelegate { 140 | func searchedMangaDidSelected(_ viewModel: SearchedMangaViewModel) { 141 | viewModel.apiId 142 | .drive(onNext: { [weak self] in 143 | guard let `self` = self else { 144 | return 145 | } 146 | 147 | self.mangaCollectionVM.fetch(id: $0) ==> self.disposeBag 148 | }) ==> self.disposeBag 149 | 150 | Router.moveTo(id: YomuRouteId.Main) 151 | } 152 | 153 | func closeView(_ sender: AnyObject?) { 154 | Router.moveTo(id: YomuRouteId.Main) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Yomu/Screens/Root/Routes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Routes.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | 11 | enum YomuRoute { 12 | case main([NSView]) 13 | case searchManga([NSView]) 14 | case chapterPage([NSView]) 15 | } 16 | 17 | enum YomuRouteId: String { 18 | case Main = "main" 19 | case SearchManga = "search-manga" 20 | case ChapterPage = "chapter-page" 21 | } 22 | 23 | extension YomuRouteId: RouteId { 24 | var name: String { 25 | return self.rawValue 26 | } 27 | } 28 | 29 | extension YomuRoute: Route { 30 | var id: RouteId { 31 | switch self { 32 | case .main: 33 | return YomuRouteId.Main 34 | 35 | case .searchManga: 36 | return YomuRouteId.SearchManga 37 | 38 | case .chapterPage: 39 | return YomuRouteId.ChapterPage 40 | } 41 | } 42 | 43 | var views: [NSView] { 44 | switch self { 45 | case .main(let _views): 46 | return _views 47 | 48 | case .searchManga(let _views): 49 | return _views 50 | 51 | case .chapterPage(let _views): 52 | return _views 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Yomu/Screens/Root/YomuWindowController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // YomuWindowController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 8/27/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | class YomuWindowController: NSWindowController { 12 | override func windowDidLoad() { 13 | super.windowDidLoad() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedManga.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchedManga.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Argo 11 | import Curry 12 | import Runes 13 | 14 | /// A data structure that represents searched manga from yomu API 15 | struct SearchedManga { 16 | /// Server database id 17 | let id: String 18 | 19 | /// An id for communicating for manga eden API 20 | let apiId: String 21 | let name: String 22 | let slug: String 23 | let image: ImageUrl 24 | let categories: [String] 25 | } 26 | 27 | extension SearchedManga: Argo.Decodable { 28 | static func decode(_ json: JSON) -> Decoded { 29 | return curry(SearchedManga.init) 30 | <^> json["_id"] 31 | <*> json["apiId"] 32 | <*> json["name"] 33 | <*> json["slug"] 34 | <*> json["imageEndPoint"] 35 | <*> json["categories"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaCollection.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaCollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchedMangaCollectionViewController.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxSwift 11 | 12 | protocol SearchedMangaDelegate: class { 13 | func searchedMangaDidSelected(_ viewModel: SearchedMangaViewModel) 14 | func closeView(_ sender: AnyObject?) 15 | } 16 | 17 | class SearchedMangaCollectionViewController: NSViewController { 18 | @IBOutlet weak var collectionView: NSCollectionView! 19 | @IBOutlet weak var mangaTitle: NSTextField! 20 | @IBOutlet weak var mangaTitleContainer: NSBox! 21 | @IBOutlet weak var progressIndicator: NSProgressIndicator! 22 | @IBOutlet weak var backButton: NSButton! 23 | 24 | weak var delegate: SearchedMangaDelegate? 25 | 26 | var viewModel: SearchedMangaCollectionViewModel 27 | 28 | let disposeBag = DisposeBag() 29 | 30 | init(viewModel: SearchedMangaCollectionViewModel) { 31 | self.viewModel = viewModel 32 | 33 | super.init(nibName: NSNib.Name(rawValue: "SearchedMangaCollection"), bundle: nil) 34 | } 35 | 36 | required init?(coder: NSCoder) { 37 | fatalError("init(coder:) has not been implemented") 38 | } 39 | 40 | override func viewDidLoad() { 41 | super.viewDidLoad() 42 | 43 | collectionView.dataSource = self 44 | collectionView.delegate = self 45 | 46 | mangaTitle 47 | .rx.text.orEmpty 48 | .filter { 49 | $0.count > 2 50 | } 51 | .throttle(1.0, scheduler: MainScheduler.instance) 52 | .distinctUntilChanged() 53 | .subscribe(onNext: { [weak self] in 54 | guard let `self` = self else { 55 | return 56 | } 57 | 58 | // Cancel previous request 59 | self.viewModel.disposeBag = DisposeBag() 60 | self.viewModel.search(term: $0) ==> self.viewModel.disposeBag 61 | }) ==> disposeBag 62 | 63 | viewModel 64 | .reload 65 | .drive(onNext: collectionView.reloadData) ==> disposeBag 66 | 67 | viewModel 68 | .fetching 69 | .drive(onNext: progressIndicator.animating) ==> disposeBag 70 | 71 | backButton 72 | .rx.tap 73 | .subscribe(onNext: { [weak self] in 74 | self?.delegate?.closeView(self) 75 | }) ==> disposeBag 76 | } 77 | 78 | override func viewWillLayout() { 79 | super.viewWillLayout() 80 | collectionView.collectionViewLayout?.invalidateLayout() 81 | } 82 | 83 | @IBAction func closeView(_ sender: NSButton) { 84 | delegate?.closeView(self) 85 | } 86 | } 87 | 88 | extension SearchedMangaCollectionViewController: NSCollectionViewDataSource { 89 | func numberOfSections(in collectionView: NSCollectionView) -> Int { 90 | return 1 91 | } 92 | 93 | func collectionView( 94 | _ collectionView: NSCollectionView, 95 | numberOfItemsInSection section: Int 96 | ) -> Int { 97 | return viewModel.count 98 | } 99 | 100 | func collectionView( 101 | _ collectionView: NSCollectionView, 102 | itemForRepresentedObjectAt indexPath: IndexPath 103 | ) -> NSCollectionViewItem { 104 | let cell = collectionView.makeItem( 105 | withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "SearchedMangaItem"), 106 | for: indexPath 107 | ) as! SearchedMangaItem 108 | let searchedMangaViewModel = viewModel[(indexPath as NSIndexPath).item] 109 | 110 | searchedMangaViewModel 111 | .title 112 | .drive(cell.titleTextField.rx.text.orEmpty) ==> cell.disposeBag 113 | 114 | searchedMangaViewModel 115 | .categoriesString 116 | .drive(cell.categoryTextField.rx.text.orEmpty) ==> cell.disposeBag 117 | 118 | searchedMangaViewModel 119 | .previewUrl 120 | .drive(onNext: cell.mangaImageView.setImageWithUrl) ==> cell.disposeBag 121 | 122 | searchedMangaViewModel 123 | .apiId 124 | .map(viewModel.isBookmarked) 125 | .drive(onNext: { isBookmarked in 126 | cell.accessoryButton.image = isBookmarked ? Config.icon.bookmarkOn : Config.icon.bookmarkOff 127 | }) ==> cell.disposeBag 128 | 129 | return cell 130 | } 131 | } 132 | 133 | extension SearchedMangaCollectionViewController: NSCollectionViewDelegateFlowLayout { 134 | func collectionView( 135 | _ collectionView: NSCollectionView, 136 | didSelectItemsAt indexPaths: Set 137 | ) { 138 | let index = (indexPaths.first! as NSIndexPath).item 139 | 140 | delegate?.searchedMangaDidSelected(viewModel[index]) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaCollectionViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchedMangaCollectionViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxMoya 11 | import RxSwift 12 | import class RealmSwift.Realm 13 | import RxRealm 14 | import Swiftz 15 | 16 | struct SearchedMangaCollectionViewModel { 17 | // MARK: Public 18 | var disposeBag = DisposeBag() 19 | 20 | // MARK: Output 21 | var count: Int { 22 | return _mangas.value.count 23 | } 24 | let reload: Driver 25 | let showViewController: Driver 26 | let fetching: Driver 27 | 28 | // MARK: Private 29 | fileprivate let _fetching = Variable(false) 30 | fileprivate let _showViewController = Variable(false) 31 | fileprivate let _mangas = Variable(List()) 32 | 33 | fileprivate var mangaIds: Set = { 34 | let mangaIds = Database.queryMangas().map { $0.id! } 35 | 36 | return Set(mangaIds) 37 | }() 38 | 39 | init() { 40 | reload = _mangas 41 | .asDriver() 42 | .map(void) 43 | 44 | fetching = _fetching.asDriver() 45 | showViewController = _fetching.asDriver() 46 | } 47 | 48 | subscript(index: Int) -> SearchedMangaViewModel { 49 | return _mangas.value[UInt(index)] 50 | } 51 | 52 | func search(term: String) -> Disposable { 53 | let api = YomuAPI.search(term) 54 | let request = Yomu.request(api) 55 | 56 | let fetchingDisposable = request 57 | .map(const(false)) 58 | .asDriver(onErrorJustReturn: false) 59 | .startWith(true) 60 | .drive(_fetching) 61 | 62 | let resultDisposable = request 63 | .filterSuccessfulStatusCodes() 64 | .mapArray(SearchedManga.self, withRootKey: "mangas") 65 | .asDriver(onErrorJustReturn: []) 66 | .map { 67 | $0.map(SearchedMangaViewModel.init) 68 | } 69 | .map(List.init) 70 | .drive(_mangas) 71 | 72 | return CompositeDisposable(fetchingDisposable, resultDisposable) 73 | } 74 | 75 | func isBookmarked(id: String) -> Bool { 76 | return mangaIds.contains(id) 77 | } 78 | 79 | func hideViewController() { 80 | _showViewController.value = false 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaCell.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/15/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import AppKit 10 | import RxSwift 11 | 12 | class SearchedMangaItem: NSCollectionViewItem { 13 | @IBOutlet weak var mangaImageView: NSImageView! 14 | @IBOutlet weak var titleContainerView: NSBox! 15 | @IBOutlet weak var titleTextField: NSTextField! 16 | @IBOutlet weak var accessoryButton: NSButton! 17 | @IBOutlet weak var categoryTextField: NSTextField! 18 | 19 | var disposeBag = DisposeBag() 20 | 21 | override func viewDidLoad() { 22 | super.viewDidLoad() 23 | 24 | mangaImageView.kf.indicatorType = .activity 25 | } 26 | 27 | override func viewWillLayout() { 28 | super.viewWillLayout() 29 | 30 | let border = Border(position: .bottom, width: 1.0, color: Config.style.borderColor) 31 | titleContainerView.drawBorder(border) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaItem.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /Yomu/Screens/SearchManga/SearchedMangaViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchedMangaViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 7/29/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import RxCocoa 10 | import RxSwift 11 | 12 | struct SearchedMangaViewModel { 13 | // MARK: Output 14 | let previewUrl: Driver 15 | let title: Driver 16 | let apiId: Driver 17 | let categoriesString: Driver 18 | 19 | // MARK: Private 20 | fileprivate let manga: Variable 21 | 22 | init(manga: SearchedManga) { 23 | self.manga = Variable(manga) 24 | 25 | previewUrl = self.manga 26 | .asDriver() 27 | .map { $0.image.url } 28 | 29 | title = self.manga 30 | .asDriver() 31 | .map { $0.name } 32 | 33 | apiId = self.manga 34 | .asDriver() 35 | .map { $0.apiId } 36 | 37 | categoriesString = self.manga 38 | .asDriver() 39 | .map { 40 | $0.categories.joined(separator: ", ") 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /YomuTests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - line_length 3 | - function_body_length 4 | -------------------------------------------------------------------------------- /YomuTests/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 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /YomuTests/Models/ChapterPageSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageSpec.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/20/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Quick 11 | import Nimble 12 | 13 | @testable import Yomu 14 | 15 | class ChapterPageSpec: QuickSpec { 16 | override func spec() { 17 | describe("[Argo Decodable] when decoded from json") { 18 | let jsonString = "[27, \"28/28dc5e693e46949930db46693fc828f83024fd9239e815fbbadfac2c.jpg\", 730, 1212 ]" 19 | let jsonData = JSONDataFromString(jsonString)! 20 | let page: ChapterPage = decode(jsonData)! 21 | 22 | it("Should decode page number") { 23 | expect(page.number) == 27 24 | } 25 | 26 | it("Should decode image url") { 27 | expect(page.image.endpoint) == "28/28dc5e693e46949930db46693fc828f83024fd9239e815fbbadfac2c.jpg" 28 | } 29 | 30 | it("Should decode page width") { 31 | expect(page.width) == 730 32 | } 33 | 34 | it("Should decode page height") { 35 | expect(page.height) == 1212 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /YomuTests/Models/ChapterSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterSpec.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/11/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Nimble 11 | import Quick 12 | 13 | @testable import Yomu 14 | 15 | class ChapterSpec: QuickSpec { 16 | override func spec() { 17 | describe("[Argo Decodable] When decoded from json") { 18 | context("and all values are not null") { 19 | let jsonString = "[700, 1415346745.0, \"Uzumaki Naruto!!\", \"545c7a3945b9ef92f1e256f7\"]" 20 | let json: AnyObject = JSONDataFromString(jsonString)! 21 | let chapter: Chapter = decode(json)! 22 | 23 | it("should decode chapter id") { 24 | expect(chapter.id) == "545c7a3945b9ef92f1e256f7" 25 | } 26 | 27 | it("should decode chapter number") { 28 | expect(chapter.number) == 700 29 | } 30 | 31 | it("should decode chapter title") { 32 | expect(chapter.title) == "Uzumaki Naruto!!" 33 | } 34 | } 35 | 36 | context("and title is null") { 37 | let json = JSON.array([ 38 | JSON.number(800), 39 | JSON.number(1888221.3), 40 | JSON.null, 41 | JSON.string("someId") 42 | ]) 43 | 44 | var chapter: Chapter? 45 | 46 | switch Chapter.decode(json) { 47 | case .success(let _chapter): 48 | chapter = _chapter 49 | 50 | case .failure(let error): 51 | print(error) 52 | } 53 | 54 | it("should decode chapter id") { 55 | expect(chapter?.id) == "someId" 56 | } 57 | 58 | it("should decode chapter number") { 59 | expect(chapter?.number) == 800 60 | } 61 | 62 | it("should decode chapter title to an empty string") { 63 | expect(chapter?.title) == "" 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /YomuTests/Models/MangaSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaSpec.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/12/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Argo 10 | import Nimble 11 | import Quick 12 | 13 | @testable import Yomu 14 | 15 | class MangaSpec: QuickSpec { 16 | override func spec() { 17 | describe("[Argo Decodable] When decoded from json") { 18 | let json = JSONDataFromFile("manga")! 19 | let manga: Manga = decode(json)! 20 | 21 | it("should set manga slug") { 22 | expect(manga.slug) == "naruto" 23 | } 24 | 25 | it("Should set manga title") { 26 | expect(manga.title) == "Naruto" 27 | } 28 | 29 | it("should set manga author") { 30 | expect(manga.author) == "KISHIMOTO Masashi" 31 | } 32 | 33 | it("should set manga image url") { 34 | let endpoint = "d1/d1cd664cefc4d19ec99603983d4e0b934e8bce91c3fccda3914ac029.png" 35 | 36 | expect(manga.image.endpoint) == endpoint 37 | expect(manga.image.description) == "\(ImageUrl.prefix)/\(endpoint)" 38 | } 39 | 40 | it("should set manga released year") { 41 | expect(manga.releasedYear) == 1999 42 | } 43 | 44 | it("should set manga description") { 45 | expect(manga.description) == "Test description" 46 | } 47 | 48 | it("should set manga categories") { 49 | expect(manga.categories) == [ 50 | "Shounen", 51 | "Comedy", 52 | "Fantasy", 53 | "Adventure", 54 | "Drama", 55 | "Action" 56 | ] 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /YomuTests/Utils/JSONFileReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JSON.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 6/12/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | func JSONDataFromString(_ jsonString: String) -> AnyObject? { 12 | let jsonData = jsonString.data(using: String.Encoding.utf8)! 13 | 14 | return try! JSONSerialization.jsonObject(with: jsonData, options: []) as AnyObject? 15 | } 16 | 17 | func JSONDataFromFile(_ filename: String) -> AnyObject? { 18 | return Bundle(for: JSONFileReader.self) 19 | .path(forResource: filename, ofType: "json") 20 | .flatMap { try? String(contentsOfFile: $0) } 21 | .flatMap(JSONDataFromString) 22 | } 23 | 24 | private class JSONFileReader {} 25 | -------------------------------------------------------------------------------- /YomuTests/ViewModels/ChapterPageViewModelSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ChapterPageViewModelSpec.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 11/2/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxSwift 12 | import RxCocoa 13 | import RxTest 14 | 15 | @testable import Yomu 16 | 17 | class ChapterPageViewModelSpec: QuickSpec { 18 | override func spec() { 19 | let scheduler = TestScheduler(initialClock: 0) 20 | let disposeBag = DisposeBag() 21 | let page = ChapterPage(number: 10, image: ImageUrl(endpoint: "sup/yo"), width: 800, height: 600) 22 | let viewModel = ChapterPageViewModel(page: page) 23 | 24 | describe(".imageUrl") { 25 | let observer = scheduler.createObserver(String.self) 26 | 27 | beforeEach { 28 | viewModel 29 | .imageUrl 30 | .map { $0.description } 31 | .drive(observer) ==> disposeBag 32 | 33 | scheduler.start() 34 | } 35 | 36 | it("should emit image url") { 37 | expect(observer.events.first!.value.element!) == "\(ImageUrl.prefix)/sup/yo" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /YomuTests/ViewModels/MangaViewModelSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MangaViewModelSpec.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 11/6/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxSwift 12 | import RxCocoa 13 | import RxTest 14 | 15 | @testable import Yomu 16 | 17 | class MangaViewModelSpec: QuickSpec { 18 | override func spec() { 19 | let scheduler = TestScheduler(initialClock: 0) 20 | let disposeBag = DisposeBag() 21 | let mangaWithCategories = { categories in 22 | Manga( 23 | position: 0, 24 | id: "shingeki", 25 | slug: "shingeki-no-kyojin", 26 | title: "Shingeki No Kyojin", 27 | author: "yep yep", 28 | image: ImageUrl(endpoint: "/some/url"), 29 | releasedYear: 1880, 30 | description: "test", 31 | categories: categories 32 | ) 33 | } 34 | 35 | describe(".categoriesString") { 36 | context("when categories is not empty") { 37 | let observer = scheduler.createObserver(String.self) 38 | 39 | let viewModel = MangaViewModel(manga: mangaWithCategories(["mystery", "action"])) 40 | 41 | beforeEach { 42 | viewModel 43 | .categoriesString 44 | .drive(observer) ==> disposeBag 45 | 46 | scheduler.start() 47 | } 48 | 49 | it("should emit categories string separated with comma") { 50 | expect(observer.events.first?.value.element) == "mystery, action" 51 | } 52 | } 53 | 54 | context("when categories is empty") { 55 | let observer = scheduler.createObserver(String.self) 56 | let viewModel = MangaViewModel(manga: mangaWithCategories([])) 57 | 58 | beforeEach { 59 | viewModel 60 | .categoriesString 61 | .drive(observer) ==> disposeBag 62 | 63 | scheduler.start() 64 | } 65 | 66 | it("should emit categories string with empty string") { 67 | expect(observer.events.first?.value.element) == "" 68 | } 69 | } 70 | 71 | context("when manga has 1 category") { 72 | let observer = scheduler.createObserver(String.self) 73 | let viewModel = MangaViewModel(manga: mangaWithCategories(["action"])) 74 | 75 | beforeEach { 76 | viewModel 77 | .categoriesString 78 | .drive(observer) ==> disposeBag 79 | 80 | scheduler.start() 81 | } 82 | 83 | it("should emit categories string without comma") { 84 | expect(observer.events.first?.value.element) == "action" 85 | } 86 | } 87 | } 88 | 89 | describe(".previewUrl") { 90 | let observer = scheduler.createObserver(String.self) 91 | 92 | let viewModel = MangaViewModel(manga: mangaWithCategories(["mystery", "action"])) 93 | 94 | beforeEach { 95 | viewModel 96 | .previewUrl 97 | .map { $0.description } 98 | .drive(observer) ==> disposeBag 99 | 100 | scheduler.start() 101 | } 102 | 103 | it("should emit preview url") { 104 | expect(observer.events.first?.value.element) == "https://cdn.mangaeden.com/mangasimg/some/url" 105 | } 106 | } 107 | 108 | describe(".title") { 109 | let observer = scheduler.createObserver(String.self) 110 | 111 | let viewModel = MangaViewModel(manga: mangaWithCategories(["mystery", "action"])) 112 | 113 | beforeEach { 114 | viewModel 115 | .title 116 | .drive(observer) ==> disposeBag 117 | 118 | scheduler.start() 119 | } 120 | 121 | it("should emit manga title") { 122 | expect(observer.events.first?.value.element) == "Shingeki No Kyojin" 123 | } 124 | } 125 | 126 | describe(".selected") { 127 | context("when loaded for the first time") { 128 | let observer = scheduler.createObserver(Bool.self) 129 | 130 | let viewModel = MangaViewModel(manga: mangaWithCategories(["mystery", "action"])) 131 | 132 | beforeEach { 133 | viewModel 134 | .selected 135 | .drive(observer) ==> disposeBag 136 | 137 | scheduler.start() 138 | } 139 | 140 | it("should emit false") { 141 | expect(observer.events.first?.value.element) == false 142 | } 143 | } 144 | 145 | context("when manga is selected") { 146 | let observer = scheduler.createObserver(Bool.self) 147 | 148 | let viewModel = MangaViewModel(manga: mangaWithCategories(["mystery", "action"])) 149 | 150 | beforeEach { 151 | viewModel 152 | .selected 153 | .drive(observer) ==> disposeBag 154 | 155 | viewModel.setSelected(true) 156 | 157 | scheduler.start() 158 | } 159 | 160 | it("should emit true") { 161 | expect(observer.events[1].value.element) == true 162 | } 163 | } 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /YomuTests/ViewModels/SearchedMangaViewModelSpec.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchedMangaViewModel.swift 3 | // Yomu 4 | // 5 | // Created by Sendy Halim on 10/22/16. 6 | // Copyright © 2016 Sendy Halim. All rights reserved. 7 | // 8 | 9 | import Quick 10 | import Nimble 11 | import RxSwift 12 | import RxCocoa 13 | import RxTest 14 | 15 | @testable import Yomu 16 | 17 | class SearchedMangaViewModelSpec: QuickSpec { 18 | override func spec() { 19 | let scheduler = TestScheduler(initialClock: 0) 20 | let disposeBag = DisposeBag() 21 | let manga = SearchedManga( 22 | id: "123", 23 | apiId: "test-api-id", 24 | name: "ghost-bustah", 25 | slug: "yo", 26 | image: ImageUrl(endpoint: "something"), 27 | categories: [] 28 | ) 29 | 30 | let viewModel = SearchedMangaViewModel(manga: manga) 31 | 32 | describe(".previewUrl") { 33 | let observer = scheduler.createObserver(String.self) 34 | 35 | beforeEach { 36 | viewModel 37 | .previewUrl 38 | .map { $0.description } 39 | .drive(observer) ==> disposeBag 40 | 41 | scheduler.start() 42 | } 43 | 44 | it("should emit manga title") { 45 | expect(observer.events.first!.value.element) == "\(ImageUrl.prefix)/something" 46 | } 47 | } 48 | 49 | describe(".title") { 50 | let observer = scheduler.createObserver(String.self) 51 | 52 | beforeEach { 53 | viewModel 54 | .title 55 | .drive(observer) ==> disposeBag 56 | 57 | scheduler.start() 58 | } 59 | 60 | it("should emit emit preview url") { 61 | expect(observer.events.first!.value.element) == "ghost-bustah" 62 | } 63 | } 64 | 65 | describe(".apiId") { 66 | let observer = scheduler.createObserver(String.self) 67 | 68 | beforeEach { 69 | viewModel 70 | .apiId 71 | .drive(observer) ==> disposeBag 72 | 73 | scheduler.start() 74 | } 75 | 76 | it("should emit apiId") { 77 | expect(observer.events.first!.value.element) == "test-api-id" 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /YomuTests/stubs/manga.json: -------------------------------------------------------------------------------- 1 | { 2 | "aka": [ 3 | "火影忍者", 4 | "NARUTO―ナルト―", 5 | "Наруто" 6 | ], 7 | "aka-alias": [ 8 | "28779244332452532773", 9 | "naruto82131249012523124888213", 10 | "105310721088109110901086" 11 | ], 12 | "alias": "naruto", 13 | "artist": "KISHIMOTO Masashi", 14 | "artist_kw": [ 15 | "kishimoto", 16 | "masashi" 17 | ], 18 | "author": "KISHIMOTO Masashi", 19 | "author_kw": [ 20 | "kishimoto", 21 | "masashi" 22 | ], 23 | "baka": true, 24 | "categories": [ 25 | "Shounen", 26 | "Comedy", 27 | "Fantasy", 28 | "Adventure", 29 | "Drama", 30 | "Action" 31 | ], 32 | "chapters": [ 33 | [ 34 | 700, 35 | 1415282983.0, 36 | "Uzumaki Naruto!!", 37 | "545b812745b9efab4343f776" 38 | ] 39 | ], 40 | "chapters_len": 818, 41 | "created": 1316022787.0, 42 | "description": "Test description", 43 | "hits": 248227265, 44 | "image": "d1/d1cd664cefc4d19ec99603983d4e0b934e8bce91c3fccda3914ac029.png", 45 | "imageURL": "http://www.mangaupdates.com/image/i140134.png", 46 | "language": 0, 47 | "last_chapter_date": 1464467825.0, 48 | "random": [ 49 | 0.39314473752533496, 50 | 0 51 | ], 52 | "released": 1999, 53 | "startsWith": "n", 54 | "status": 2, 55 | "title": "Naruto", 56 | "title_kw": [ 57 | "naruto" 58 | ], 59 | "type": 0, 60 | "updatedKeywords": true 61 | } 62 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Yomu 2 | Manga Reader App for Mac OS, feel free to request new features on [issue tracker](https://github.com/sendyhalim/yomu/issues) 3 | 4 | [![Build Status](https://img.shields.io/travis/sendyhalim/Yomu.svg?style=flat-square)](https://travis-ci.org/sendyhalim/Yomu) [![GitHub release](https://img.shields.io/github/release/sendyhalim/yomu.svg?style=flat-square)]() 5 | 6 | Yomu screenshot 7 | 8 | ## Requirements 9 | Mac OS 10.11+ 10 | 11 | ## Install 12 | 13 | ### Download 14 | Latest yomu installer can be found [here](https://github.com/sendyhalim/yomu/releases/latest) 15 | 16 | ### Build manually 17 | 1. `git clone https://github.com/sendyhalim/yomu` 18 | 2. `make package` 19 | 3. Open the directory, there will be a yomu installer `Yomu.pkg` 20 | 4. Double click `Yomu.pkg` 21 | 22 | ## Navigation 23 | There are 4 keyboard navigation event handlers on chapter collection view 24 | - `h` Go to previous chapter 25 | - `l` Go to next chapter 26 | - `j` Scroll down 27 | - `k` Scroll up 28 | 29 | ## Libraries 30 | - [Argo](https://github.com/thoughtbot/Argo) 31 | - [Cartography](https://github.com/robb/Cartography) 32 | - [Curry](https://github.com/thoughtbot/Curry) 33 | - [Hue](https://github.com/hyperoslo/Hue) 34 | - [King Fisher](https://github.com/onevcat/Kingfisher) 35 | - [Moya](https://github.com/Moya/Moya) 36 | - [Nimble](https://github.com/Quick/Nimble) 37 | - [OrderedSet](https://github.com/sendyhalim/OrderedSet) 38 | - [Quick](https://github.com/Quick/Quick) 39 | - [Realm](https://github.com/realm/realm-cocoa) 40 | - [RxRealm](https://github.com/RxSwiftCommunity/RxRealm) 41 | - [RxSwift](https://github.com/ReactiveX/RxSwift) 42 | - [Swiftz](https://github.com/typelift/Swiftz) 43 | 44 | 45 | ## Why another manga reader? 46 | Just for hobby 47 | 48 | ## Acknowledgements 49 | Free icons used in this app are provided by [icons8](https://icons8.com) 50 | 51 | Content provided by [Manga Eden](http://www.mangaeden.com/api) (I do not host the content) 52 | 53 | ## License 54 | MIT ~ 55 | 56 | ![Yeay](https://media1.giphy.com/media/ZHjSXzRkUWTWE/200.gif) 57 | --------------------------------------------------------------------------------