diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..0ebc1d4 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,34 @@ +version: 2.1 + +orbs: + # Using 1.0 of our Orbs means it will use the latest 1.0.x version from https://github.com/wordpress-mobile/circleci-orbs + ios: wordpress-mobile/ios@1.0 + +workflows: + test_and_validate: + jobs: + - ios/test: + name: Test + xcode-version: "11.2.1" + project: MediaEditor.xcodeproj + scheme: Example + device: iPhone 11 + ios-version: "13.2.2" + bundle-install: true + carthage-update: true + - ios/validate-podspec: + name: Validate Podspec + xcode-version: "11.2.1" + podspec-path: MediaEditor.podspec + bundle-install: true + - ios/publish-podspec: + name: Publish to Trunk + xcode-version: "11.2.1" + podspec-path: MediaEditor.podspec + bundle-install: true + post-to-slack: true + filters: + tags: + only: /.*/ + branches: + ignore: /.*/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..daf21fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,202 @@ +### fastlane ### +# fastlane - A streamlined workflow tool for Cocoa deployment +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +# fastlane specific +fastlane/report.xml + +# deliver temporary files +fastlane/Preview.html + +# snapshot generated screenshots +fastlane/screenshots/**/*.png +fastlane/screenshots/screenshots.html + +# scan temporary files +fastlane/test_output + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Ruby ### +*.gem +*.rbc +/.config +/coverage/ +/InstalledFiles +/pkg/ +/spec/reports/ +/spec/examples.txt +/test/tmp/ +/test/version_tmp/ +/tmp/ + +# Used by dotenv library to load environment variables. +# .env + +# Ignore Byebug command history file. +.byebug_history + +## Specific to RubyMotion: +.dat* +.repl_history +build/ +*.bridgesupport +build-iPhoneOS/ +build-iPhoneSimulator/ + +## Specific to RubyMotion (use of CocoaPods): +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# vendor/Pods/ + +## Documentation cache and generated files: +/.yardoc/ +/_yardoc/ +/doc/ +/rdoc/ + +## Environment normalization: +/.bundle/ +/vendor/bundle +/lib/bundler/man/ + +# for a library or gem, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# Gemfile.lock +# .ruby-version +# .ruby-gemset + +# unless supporting rvm < 1.11.0 or doing something fancy, ignore this: +.rvmrc + +### Ruby Patch ### +# Used by RuboCop. Remote config files pulled in from inherit_from directive. +# .rubocop-https?--* + +### Swift ### +# Xcode +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +.build/ +# Add this line if you want to avoid checking in Xcode SPM integration. +# .swiftpm/xcode + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### Xcode ### +# Xcode +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) + +## Xcode Patch +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno + +### Xcode Patch ### +**/xcshareddata/WorkspaceSettings.xcsettings + +### Carthage ### +Carthage/Checkouts +Carthage/Build diff --git a/Cartfile b/Cartfile new file mode 100644 index 0000000..e1d730e --- /dev/null +++ b/Cartfile @@ -0,0 +1,2 @@ +github "TimOliver/TOCropViewController" +github "Quick/Nimble" diff --git a/Cartfile.resolved b/Cartfile.resolved new file mode 100644 index 0000000..0d4ea38 --- /dev/null +++ b/Cartfile.resolved @@ -0,0 +1,2 @@ +github "Quick/Nimble" "v8.0.5" +github "TimOliver/TOCropViewController" "2.5.2" diff --git a/Example/AppDelegate.swift b/Example/AppDelegate.swift new file mode 100644 index 0000000..34ca27d --- /dev/null +++ b/Example/AppDelegate.swift @@ -0,0 +1,29 @@ +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Assets.xcassets/Contents.json b/Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Example/Base.lproj/LaunchScreen.storyboard b/Example/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Example/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Base.lproj/Main.storyboard b/Example/Base.lproj/Main.storyboard new file mode 100644 index 0000000..25a7638 --- /dev/null +++ b/Example/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Info.plist b/Example/Info.plist new file mode 100644 index 0000000..2a3483c --- /dev/null +++ b/Example/Info.plist @@ -0,0 +1,64 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Example/SceneDelegate.swift b/Example/SceneDelegate.swift new file mode 100644 index 0000000..8778aa8 --- /dev/null +++ b/Example/SceneDelegate.swift @@ -0,0 +1,45 @@ +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/Example/ViewController.swift b/Example/ViewController.swift new file mode 100644 index 0000000..ded49dd --- /dev/null +++ b/Example/ViewController.swift @@ -0,0 +1,12 @@ +import UIKit + +class ViewController: UIViewController { + + override func viewDidLoad() { + super.viewDidLoad() + // Do any additional setup after loading the view. + } + + +} + diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c1535c5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' do + gem 'cocoapods', '~> 1.8.0' + gem 'xcpretty' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..23ee70e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,87 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.2) + activesupport (4.2.11.1) + i18n (~> 0.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + algoliasearch (1.27.1) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + claide (1.0.3) + cocoapods (1.8.4) + activesupport (>= 4.0.2, < 5) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.8.4) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 1.2.2, < 2.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-stats (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.6.6) + nap (~> 1.0) + ruby-macho (~> 1.4) + xcodeproj (>= 1.11.1, < 2.0) + cocoapods-core (1.8.4) + activesupport (>= 4.0.2, < 6) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + cocoapods-deintegrate (1.0.4) + cocoapods-downloader (1.3.0) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.0) + cocoapods-stats (1.1.0) + cocoapods-trunk (1.4.1) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.1.0) + colored2 (3.1.2) + concurrent-ruby (1.1.5) + escape (0.0.4) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.8.3) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + json (2.3.0) + minitest (5.14.0) + molinillo (0.6.6) + nanaimo (0.2.6) + nap (1.1.0) + netrc (0.11.0) + rouge (2.0.7) + ruby-macho (1.4.0) + thread_safe (0.3.6) + tzinfo (1.2.6) + thread_safe (~> 0.1) + xcodeproj (1.14.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.2.6) + xcpretty (0.3.0) + rouge (~> 2.0.7) + +PLATFORMS + ruby + +DEPENDENCIES + cocoapods (~> 1.8.0)! + xcpretty! + +BUNDLED WITH + 1.17.2 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36b630d --- /dev/null +++ b/LICENSE @@ -0,0 +1,280 @@ +GNU GENERAL PUBLIC LICENSE +Version 2, June 1991 + +Copyright (C) 1989, 1991 Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + +Preamble + +The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + +To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + +We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + +Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + +Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + +The precise terms and conditions for copying, distribution and +modification follow. + +GNU GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + +1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + +a) You must cause the modified files to carry prominent notices +stating that you changed the files and the date of any change. + +b) You must cause any work that you distribute or publish, that in +whole or in part contains or is derived from the Program or any +part thereof, to be licensed as a whole at no charge to all third +parties under the terms of this License. + +c) If the modified program normally reads commands interactively +when run, you must cause it, when started running for such +interactive use in the most ordinary way, to print or display an +announcement including an appropriate copyright notice and a +notice that there is no warranty (or else, saying that you provide +a warranty) and that users may redistribute the program under +these conditions, and telling the user how to view a copy of this +License. (Exception: if the Program itself is interactive but +does not normally print such an announcement, your work based on +the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + +3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + +a) Accompany it with the complete corresponding machine-readable +source code, which must be distributed under the terms of Sections +1 and 2 above on a medium customarily used for software interchange; or, + +b) Accompany it with a written offer, valid for at least three +years, to give any third party, for a charge no more than your +cost of physically performing source distribution, a complete +machine-readable copy of the corresponding source code, to be +distributed under the terms of Sections 1 and 2 above on a medium +customarily used for software interchange; or, + +c) Accompany it with the information you received as to the offer +to distribute corresponding source code. (This alternative is +allowed only for noncommercial distribution and only if you +received the program in object code or executable form with such +an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + +4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + +5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + +6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + +7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + +8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + +9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + +10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + +NO WARRANTY + +11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + +12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS diff --git a/MediaEditor.podspec b/MediaEditor.podspec new file mode 100644 index 0000000..ffb7f67 --- /dev/null +++ b/MediaEditor.podspec @@ -0,0 +1,26 @@ +Pod::Spec.new do |s| + s.name = 'MediaEditor' + s.version = '0.1.2' + s.summary = 'An extensible Media Editor for iOS.' + + s.description = <<-DESC + An extensible Media Editor for iOS that allows editing single or multiple images. + DESC + + s.homepage = 'https://github.com/wordpress-mobile/MediaEditor-iOS.git' + s.license = { :type => 'GPLv2', :file => 'LICENSE' } + s.author = { 'Automattic' => 'mobile@automattic.com' } + s.social_media_url = "http://twitter.com/WordPressiOS" + s.source = { :git => 'https://github.com/wordpress-mobile/MediaEditor-iOS.git', :tag => s.version.to_s } + s.ios.deployment_target = '11.0' + s.swift_version = '5.0' + + s.module_name = "MediaEditor" + s.source_files = 'Sources/**/*.{h,m,swift}' + s.resources = 'Sources/**/*.{storyboard}' + s.resource_bundles = { + 'MediaEditor' => 'Sources/**/*.{xcassets}' + } + + s.dependency 'TOCropViewController', '~> 2.5.2' +end diff --git a/MediaEditor.xcodeproj/project.pbxproj b/MediaEditor.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a2006c2 --- /dev/null +++ b/MediaEditor.xcodeproj/project.pbxproj @@ -0,0 +1,844 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 8B11A3CF23D8F590000721F5 /* Bundle+mediaEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B11A3CE23D8F590000721F5 /* Bundle+mediaEditor.swift */; }; + 8B50469723D7C88200068F66 /* MediaEditor.h in Headers */ = {isa = PBXBuildFile; fileRef = 8B50469523D7C88200068F66 /* MediaEditor.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 8B5046A423D7C9AC00068F66 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046A323D7C9AC00068F66 /* AppDelegate.swift */; }; + 8B5046A623D7C9AC00068F66 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046A523D7C9AC00068F66 /* SceneDelegate.swift */; }; + 8B5046A823D7C9AC00068F66 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046A723D7C9AC00068F66 /* ViewController.swift */; }; + 8B5046AB23D7C9AC00068F66 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B5046A923D7C9AC00068F66 /* Main.storyboard */; }; + 8B5046AD23D7C9AD00068F66 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8B5046AC23D7C9AD00068F66 /* Assets.xcassets */; }; + 8B5046B023D7C9AD00068F66 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B5046AE23D7C9AD00068F66 /* LaunchScreen.storyboard */; }; + 8B5046B623D7C9E300068F66 /* MediaEditor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B50469223D7C88200068F66 /* MediaEditor.framework */; }; + 8B5046B823D7CCBD00068F66 /* TOCropViewController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */; }; + 8B5046B923D7CCBD00068F66 /* TOCropViewController.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 8B5046BB23D7CCD000068F66 /* TOCropViewController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */; }; + 8B5046BC23D7CCD000068F66 /* TOCropViewController.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 8B5046D623D7CE1600068F66 /* UIImage+AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046C423D7CE1600068F66 /* UIImage+AsyncImage.swift */; }; + 8B5046D723D7CE1600068F66 /* PHAsset+AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046C523D7CE1600068F66 /* PHAsset+AsyncImage.swift */; }; + 8B5046D823D7CE1600068F66 /* MediaEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046C623D7CE1600068F66 /* MediaEditor.swift */; }; + 8B5046D923D7CE1600068F66 /* MediaEditorImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046C723D7CE1600068F66 /* MediaEditorImageCell.swift */; }; + 8B5046DA23D7CE1600068F66 /* TOCropViewController+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046CA23D7CE1600068F66 /* TOCropViewController+Ext.swift */; }; + 8B5046DB23D7CE1600068F66 /* MediaEditorCropZoomRotate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046CB23D7CE1600068F66 /* MediaEditorCropZoomRotate.swift */; }; + 8B5046DC23D7CE1600068F66 /* MediaEditorCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046CC23D7CE1600068F66 /* MediaEditorCapability.swift */; }; + 8B5046DD23D7CE1600068F66 /* MediaEditorHub.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046CD23D7CE1600068F66 /* MediaEditorHub.swift */; }; + 8B5046DE23D7CE1600068F66 /* AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046CE23D7CE1600068F66 /* AsyncImage.swift */; }; + 8B5046DF23D7CE1600068F66 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8B5046CF23D7CE1600068F66 /* Media.xcassets */; }; + 8B5046E023D7CE1600068F66 /* MediaEditorHub.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B5046D023D7CE1600068F66 /* MediaEditorHub.storyboard */; }; + 8B5046E123D7CE1600068F66 /* MediaEditorCapabilityCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046D123D7CE1600068F66 /* MediaEditorCapabilityCell.swift */; }; + 8B5046E223D7CE1600068F66 /* MediaEditorThumbCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046D223D7CE1600068F66 /* MediaEditorThumbCell.swift */; }; + 8B5046E323D7CE1600068F66 /* MediaEditorStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046D423D7CE1600068F66 /* MediaEditorStyle.swift */; }; + 8B5046E423D7CE1600068F66 /* MediaEditorOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5046D523D7CE1600068F66 /* MediaEditorOperation.swift */; }; + 8B50472723D7D36C00068F66 /* MediaEditorHubTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50472223D7D36C00068F66 /* MediaEditorHubTests.swift */; }; + 8B50472823D7D36C00068F66 /* MediaEditorCropZoomRotateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50472523D7D36C00068F66 /* MediaEditorCropZoomRotateTests.swift */; }; + 8B50472923D7D36C00068F66 /* MediaEditorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50472623D7D36C00068F66 /* MediaEditorTests.swift */; }; + 8B50472A23D7D39900068F66 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8B5046FC23D7D16A00068F66 /* Nimble.framework */; platformFilter = ios; }; + 8B50472D23D87C3500068F66 /* UIApplication+topWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50472C23D87C3500068F66 /* UIApplication+topWindow.swift */; }; + 8B50472F23D87DA400068F66 /* UIImage+color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B50472E23D87DA400068F66 /* UIImage+color.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 8B50471D23D7D32B00068F66 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 8B50468923D7C88200068F66 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8B5046A023D7C9AC00068F66; + remoteInfo = Example; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 8B5046BA23D7CCBD00068F66 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 8B5046B923D7CCBD00068F66 /* TOCropViewController.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + 8B5046BD23D7CCD000068F66 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 8B5046BC23D7CCD000068F66 /* TOCropViewController.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 8B11A3CE23D8F590000721F5 /* Bundle+mediaEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+mediaEditor.swift"; sourceTree = ""; }; + 8B50469223D7C88200068F66 /* MediaEditor.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MediaEditor.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B50469523D7C88200068F66 /* MediaEditor.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MediaEditor.h; sourceTree = ""; }; + 8B50469623D7C88200068F66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8B5046A123D7C9AC00068F66 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B5046A323D7C9AC00068F66 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 8B5046A523D7C9AC00068F66 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 8B5046A723D7C9AC00068F66 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 8B5046AA23D7C9AC00068F66 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 8B5046AC23D7C9AD00068F66 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 8B5046AF23D7C9AD00068F66 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 8B5046B123D7C9AD00068F66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TOCropViewController.framework; path = Carthage/Build/iOS/TOCropViewController.framework; sourceTree = ""; }; + 8B5046C423D7CE1600068F66 /* UIImage+AsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+AsyncImage.swift"; sourceTree = ""; }; + 8B5046C523D7CE1600068F66 /* PHAsset+AsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PHAsset+AsyncImage.swift"; sourceTree = ""; }; + 8B5046C623D7CE1600068F66 /* MediaEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditor.swift; sourceTree = ""; }; + 8B5046C723D7CE1600068F66 /* MediaEditorImageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorImageCell.swift; sourceTree = ""; }; + 8B5046CA23D7CE1600068F66 /* TOCropViewController+Ext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TOCropViewController+Ext.swift"; sourceTree = ""; }; + 8B5046CB23D7CE1600068F66 /* MediaEditorCropZoomRotate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorCropZoomRotate.swift; sourceTree = ""; }; + 8B5046CC23D7CE1600068F66 /* MediaEditorCapability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorCapability.swift; sourceTree = ""; }; + 8B5046CD23D7CE1600068F66 /* MediaEditorHub.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorHub.swift; sourceTree = ""; }; + 8B5046CE23D7CE1600068F66 /* AsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncImage.swift; sourceTree = ""; }; + 8B5046CF23D7CE1600068F66 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; + 8B5046D023D7CE1600068F66 /* MediaEditorHub.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MediaEditorHub.storyboard; sourceTree = ""; }; + 8B5046D123D7CE1600068F66 /* MediaEditorCapabilityCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorCapabilityCell.swift; sourceTree = ""; }; + 8B5046D223D7CE1600068F66 /* MediaEditorThumbCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorThumbCell.swift; sourceTree = ""; }; + 8B5046D423D7CE1600068F66 /* MediaEditorStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorStyle.swift; sourceTree = ""; }; + 8B5046D523D7CE1600068F66 /* MediaEditorOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorOperation.swift; sourceTree = ""; }; + 8B5046FC23D7D16A00068F66 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = Carthage/Build/iOS/Nimble.framework; sourceTree = ""; }; + 8B50471823D7D32B00068F66 /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8B50471C23D7D32B00068F66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8B50472223D7D36C00068F66 /* MediaEditorHubTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorHubTests.swift; sourceTree = ""; }; + 8B50472523D7D36C00068F66 /* MediaEditorCropZoomRotateTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorCropZoomRotateTests.swift; sourceTree = ""; }; + 8B50472623D7D36C00068F66 /* MediaEditorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEditorTests.swift; sourceTree = ""; }; + 8B50472C23D87C3500068F66 /* UIApplication+topWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+topWindow.swift"; sourceTree = ""; }; + 8B50472E23D87DA400068F66 /* UIImage+color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+color.swift"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8B50468F23D7C88200068F66 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046BB23D7CCD000068F66 /* TOCropViewController.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50469E23D7C9AC00068F66 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046B823D7CCBD00068F66 /* TOCropViewController.framework in Frameworks */, + 8B5046B623D7C9E300068F66 /* MediaEditor.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50471523D7D32B00068F66 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B50472A23D7D39900068F66 /* Nimble.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8B50468823D7C88200068F66 = { + isa = PBXGroup; + children = ( + 8B50469423D7C88200068F66 /* Sources */, + 8B5046A223D7C9AC00068F66 /* Example */, + 8B50471923D7D32B00068F66 /* Tests */, + 8B50469323D7C88200068F66 /* Products */, + 8B5046B523D7C9E300068F66 /* Frameworks */, + ); + sourceTree = ""; + }; + 8B50469323D7C88200068F66 /* Products */ = { + isa = PBXGroup; + children = ( + 8B50469223D7C88200068F66 /* MediaEditor.framework */, + 8B5046A123D7C9AC00068F66 /* Example.app */, + 8B50471823D7D32B00068F66 /* Tests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 8B50469423D7C88200068F66 /* Sources */ = { + isa = PBXGroup; + children = ( + 8B5046C823D7CE1600068F66 /* Capabilities */, + 8B5046D323D7CE1600068F66 /* Enums */, + 8B5046C323D7CE1600068F66 /* Extensions */, + 8B5046CE23D7CE1600068F66 /* AsyncImage.swift */, + 8B5046CF23D7CE1600068F66 /* Media.xcassets */, + 8B5046C623D7CE1600068F66 /* MediaEditor.swift */, + 8B5046D123D7CE1600068F66 /* MediaEditorCapabilityCell.swift */, + 8B5046D023D7CE1600068F66 /* MediaEditorHub.storyboard */, + 8B5046CD23D7CE1600068F66 /* MediaEditorHub.swift */, + 8B5046C723D7CE1600068F66 /* MediaEditorImageCell.swift */, + 8B5046D223D7CE1600068F66 /* MediaEditorThumbCell.swift */, + 8B50469523D7C88200068F66 /* MediaEditor.h */, + 8B50469623D7C88200068F66 /* Info.plist */, + ); + path = Sources; + sourceTree = ""; + }; + 8B5046A223D7C9AC00068F66 /* Example */ = { + isa = PBXGroup; + children = ( + 8B5046A323D7C9AC00068F66 /* AppDelegate.swift */, + 8B5046A523D7C9AC00068F66 /* SceneDelegate.swift */, + 8B5046A723D7C9AC00068F66 /* ViewController.swift */, + 8B5046A923D7C9AC00068F66 /* Main.storyboard */, + 8B5046AC23D7C9AD00068F66 /* Assets.xcassets */, + 8B5046AE23D7C9AD00068F66 /* LaunchScreen.storyboard */, + 8B5046B123D7C9AD00068F66 /* Info.plist */, + ); + path = Example; + sourceTree = ""; + }; + 8B5046B523D7C9E300068F66 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8B5046FC23D7D16A00068F66 /* Nimble.framework */, + 8B5046B723D7CCBD00068F66 /* TOCropViewController.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 8B5046C323D7CE1600068F66 /* Extensions */ = { + isa = PBXGroup; + children = ( + 8B5046C423D7CE1600068F66 /* UIImage+AsyncImage.swift */, + 8B5046C523D7CE1600068F66 /* PHAsset+AsyncImage.swift */, + 8B11A3CE23D8F590000721F5 /* Bundle+mediaEditor.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 8B5046C823D7CE1600068F66 /* Capabilities */ = { + isa = PBXGroup; + children = ( + 8B5046C923D7CE1600068F66 /* Crop */, + 8B5046CC23D7CE1600068F66 /* MediaEditorCapability.swift */, + ); + path = Capabilities; + sourceTree = ""; + }; + 8B5046C923D7CE1600068F66 /* Crop */ = { + isa = PBXGroup; + children = ( + 8B5046CA23D7CE1600068F66 /* TOCropViewController+Ext.swift */, + 8B5046CB23D7CE1600068F66 /* MediaEditorCropZoomRotate.swift */, + ); + path = Crop; + sourceTree = ""; + }; + 8B5046D323D7CE1600068F66 /* Enums */ = { + isa = PBXGroup; + children = ( + 8B5046D423D7CE1600068F66 /* MediaEditorStyle.swift */, + 8B5046D523D7CE1600068F66 /* MediaEditorOperation.swift */, + ); + path = Enums; + sourceTree = ""; + }; + 8B50471923D7D32B00068F66 /* Tests */ = { + isa = PBXGroup; + children = ( + 8B50472323D7D36C00068F66 /* Capabilities */, + 8B50472B23D87B9900068F66 /* Extensions */, + 8B50472223D7D36C00068F66 /* MediaEditorHubTests.swift */, + 8B50472623D7D36C00068F66 /* MediaEditorTests.swift */, + 8B50471C23D7D32B00068F66 /* Info.plist */, + ); + path = Tests; + sourceTree = ""; + }; + 8B50472323D7D36C00068F66 /* Capabilities */ = { + isa = PBXGroup; + children = ( + 8B50472423D7D36C00068F66 /* Crop */, + ); + path = Capabilities; + sourceTree = ""; + }; + 8B50472423D7D36C00068F66 /* Crop */ = { + isa = PBXGroup; + children = ( + 8B50472523D7D36C00068F66 /* MediaEditorCropZoomRotateTests.swift */, + ); + path = Crop; + sourceTree = ""; + }; + 8B50472B23D87B9900068F66 /* Extensions */ = { + isa = PBXGroup; + children = ( + 8B50472C23D87C3500068F66 /* UIApplication+topWindow.swift */, + 8B50472E23D87DA400068F66 /* UIImage+color.swift */, + ); + path = Extensions; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 8B50468D23D7C88200068F66 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B50469723D7C88200068F66 /* MediaEditor.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 8B50469123D7C88200068F66 /* MediaEditor */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B50469A23D7C88200068F66 /* Build configuration list for PBXNativeTarget "MediaEditor" */; + buildPhases = ( + 8B50468D23D7C88200068F66 /* Headers */, + 8B50468E23D7C88200068F66 /* Sources */, + 8B50468F23D7C88200068F66 /* Frameworks */, + 8B50469023D7C88200068F66 /* Resources */, + 8B5046BD23D7CCD000068F66 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MediaEditor; + productName = MediaEditor; + productReference = 8B50469223D7C88200068F66 /* MediaEditor.framework */; + productType = "com.apple.product-type.framework"; + }; + 8B5046A023D7C9AC00068F66 /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B5046B423D7C9AD00068F66 /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + 8B50469D23D7C9AC00068F66 /* Sources */, + 8B50469E23D7C9AC00068F66 /* Frameworks */, + 8B50469F23D7C9AC00068F66 /* Resources */, + 8B5046BA23D7CCBD00068F66 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Example; + productName = Example; + productReference = 8B5046A123D7C9AC00068F66 /* Example.app */; + productType = "com.apple.product-type.application"; + }; + 8B50471723D7D32B00068F66 /* Tests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8B50471F23D7D32B00068F66 /* Build configuration list for PBXNativeTarget "Tests" */; + buildPhases = ( + 8B50471423D7D32B00068F66 /* Sources */, + 8B50471523D7D32B00068F66 /* Frameworks */, + 8B50471623D7D32B00068F66 /* Resources */, + 8B50473123D87EE000068F66 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 8B50471E23D7D32B00068F66 /* PBXTargetDependency */, + ); + name = Tests; + productName = Tests; + productReference = 8B50471823D7D32B00068F66 /* Tests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8B50468923D7C88200068F66 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1120; + LastUpgradeCheck = 1120; + ORGANIZATIONNAME = "Automattic, Inc."; + TargetAttributes = { + 8B50469123D7C88200068F66 = { + CreatedOnToolsVersion = 11.2.1; + LastSwiftMigration = 1120; + }; + 8B5046A023D7C9AC00068F66 = { + CreatedOnToolsVersion = 11.2.1; + }; + 8B50471723D7D32B00068F66 = { + CreatedOnToolsVersion = 11.2.1; + LastSwiftMigration = 1120; + TestTargetID = 8B5046A023D7C9AC00068F66; + }; + }; + }; + buildConfigurationList = 8B50468C23D7C88200068F66 /* Build configuration list for PBXProject "MediaEditor" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8B50468823D7C88200068F66; + productRefGroup = 8B50469323D7C88200068F66 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8B50469123D7C88200068F66 /* MediaEditor */, + 8B5046A023D7C9AC00068F66 /* Example */, + 8B50471723D7D32B00068F66 /* Tests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8B50469023D7C88200068F66 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046E023D7CE1600068F66 /* MediaEditorHub.storyboard in Resources */, + 8B5046DF23D7CE1600068F66 /* Media.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50469F23D7C9AC00068F66 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046B023D7C9AD00068F66 /* LaunchScreen.storyboard in Resources */, + 8B5046AD23D7C9AD00068F66 /* Assets.xcassets in Resources */, + 8B5046AB23D7C9AC00068F66 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50471623D7D32B00068F66 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 8B50473123D87EE000068F66 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Carthage/Build/iOS/Nimble.framework", + ); + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Nimble.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/usr/local/bin/carthage copy-frameworks\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8B50468E23D7C88200068F66 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046E223D7CE1600068F66 /* MediaEditorThumbCell.swift in Sources */, + 8B5046D623D7CE1600068F66 /* UIImage+AsyncImage.swift in Sources */, + 8B5046DE23D7CE1600068F66 /* AsyncImage.swift in Sources */, + 8B5046DC23D7CE1600068F66 /* MediaEditorCapability.swift in Sources */, + 8B5046DB23D7CE1600068F66 /* MediaEditorCropZoomRotate.swift in Sources */, + 8B5046E323D7CE1600068F66 /* MediaEditorStyle.swift in Sources */, + 8B5046D823D7CE1600068F66 /* MediaEditor.swift in Sources */, + 8B11A3CF23D8F590000721F5 /* Bundle+mediaEditor.swift in Sources */, + 8B5046DA23D7CE1600068F66 /* TOCropViewController+Ext.swift in Sources */, + 8B5046E423D7CE1600068F66 /* MediaEditorOperation.swift in Sources */, + 8B5046DD23D7CE1600068F66 /* MediaEditorHub.swift in Sources */, + 8B5046E123D7CE1600068F66 /* MediaEditorCapabilityCell.swift in Sources */, + 8B5046D723D7CE1600068F66 /* PHAsset+AsyncImage.swift in Sources */, + 8B5046D923D7CE1600068F66 /* MediaEditorImageCell.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50469D23D7C9AC00068F66 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B5046A823D7C9AC00068F66 /* ViewController.swift in Sources */, + 8B5046A423D7C9AC00068F66 /* AppDelegate.swift in Sources */, + 8B5046A623D7C9AC00068F66 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8B50471423D7D32B00068F66 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8B50472D23D87C3500068F66 /* UIApplication+topWindow.swift in Sources */, + 8B50472F23D87DA400068F66 /* UIImage+color.swift in Sources */, + 8B50472723D7D36C00068F66 /* MediaEditorHubTests.swift in Sources */, + 8B50472923D7D36C00068F66 /* MediaEditorTests.swift in Sources */, + 8B50472823D7D36C00068F66 /* MediaEditorCropZoomRotateTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 8B50471E23D7D32B00068F66 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8B5046A023D7C9AC00068F66 /* Example */; + targetProxy = 8B50471D23D7D32B00068F66 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 8B5046A923D7C9AC00068F66 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 8B5046AA23D7C9AC00068F66 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 8B5046AE23D7C9AD00068F66 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 8B5046AF23D7C9AD00068F66 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 8B50469823D7C88200068F66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 8B50469923D7C88200068F66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 8B50469B23D7C88200068F66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Sources/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.MediaEditor; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8B50469C23D7C88200068F66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Sources/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.MediaEditor; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTS_MACCATALYST = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8B5046B223D7C9AD00068F66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = PZYM8XX95Q; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Example/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 8B5046B323D7C9AD00068F66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = PZYM8XX95Q; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Example/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 8B50472023D7D32B00068F66 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = PZYM8XX95Q; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Tests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/Example"; + }; + name = Debug; + }; + 8B50472123D7D32B00068F66 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = PZYM8XX95Q; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Carthage/Build/iOS", + ); + INFOPLIST_FILE = Tests/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.Tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/Example"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8B50468C23D7C88200068F66 /* Build configuration list for PBXProject "MediaEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B50469823D7C88200068F66 /* Debug */, + 8B50469923D7C88200068F66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B50469A23D7C88200068F66 /* Build configuration list for PBXNativeTarget "MediaEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B50469B23D7C88200068F66 /* Debug */, + 8B50469C23D7C88200068F66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B5046B423D7C9AD00068F66 /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B5046B223D7C9AD00068F66 /* Debug */, + 8B5046B323D7C9AD00068F66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8B50471F23D7D32B00068F66 /* Build configuration list for PBXNativeTarget "Tests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8B50472023D7D32B00068F66 /* Debug */, + 8B50472123D7D32B00068F66 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8B50468923D7C88200068F66 /* Project object */; +} diff --git a/Sources/AsyncImage.swift b/Sources/AsyncImage.swift new file mode 100644 index 0000000..227ab7e --- /dev/null +++ b/Sources/AsyncImage.swift @@ -0,0 +1,40 @@ +import UIKit + +public protocol AsyncImage { + var thumb: UIImage? { get } + + var isEdited: Bool { get set } + + var editedImage: UIImage? { get set } + + func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) + + func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) + + func cancel() +} + +extension AsyncImage { + public var isEdited: Bool { + get { + return objc_getAssociatedObject(self, &AsyncImageKeys.isEdited) as? Bool ?? false + } + set { + objc_setAssociatedObject(self, &AsyncImageKeys.isEdited, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + public var editedImage: UIImage? { + get { + return objc_getAssociatedObject(self, &AsyncImageKeys.editedImage) as? UIImage ?? nil + } + set { + objc_setAssociatedObject(self, &AsyncImageKeys.editedImage, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } +} + +private enum AsyncImageKeys { + static var isEdited = "isEdited" + static var editedImage = "editedImage" +} diff --git a/Sources/Capabilities/Crop/MediaEditorCropZoomRotate.swift b/Sources/Capabilities/Crop/MediaEditorCropZoomRotate.swift new file mode 100644 index 0000000..bef64d6 --- /dev/null +++ b/Sources/Capabilities/Crop/MediaEditorCropZoomRotate.swift @@ -0,0 +1,81 @@ +import UIKit +import TOCropViewController + +class MediaEditorCropZoomRotate: NSObject, MediaEditorCapability { + static var name = "Crop, Zoom, Rotate" + + static var icon = UIImage(named: "gridicons-crop", in: .mediaEditor, compatibleWith: nil)! + + var image: UIImage + + var onFinishEditing: (UIImage, [MediaEditorOperation]) -> () + + var onCancel: (() -> ()) + + lazy var viewController: UIViewController = { + let cropViewController = TOCropViewController(image: image) + + cropViewController.hidesNavigationBar = false + + cropViewController.delegate = self + + return cropViewController + }() + + required init(_ image: UIImage, + onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), + onCancel: @escaping () -> ()) { + self.image = image + self.onFinishEditing = onFinishEditing + self.onCancel = onCancel + } + + func apply(styles: MediaEditorStyles) { + guard let viewController = viewController as? TOCropViewController else { + return + } + + if let doneLabel = styles[.doneLabel] as? String { + viewController.toolbar.doneTextButton.setTitle(doneLabel, for: .normal) + } + + if let cancelLabel = styles[.cancelLabel] as? String { + viewController.toolbar.cancelTextButton.setTitle(cancelLabel, for: .normal) + } + + if let cancelColor = styles[.cancelColor] as? UIColor { + viewController.toolbar.cancelTextButton.tintColor = cancelColor + viewController.toolbar.cancelIconButton.tintColor = cancelColor + } + + if let resetIcon = styles[.resetIcon] as? UIImage { + viewController.toolbar.resetButton.setImage(resetIcon, for: .normal) + } + + if let doneIcon = styles[.doneIcon] as? UIImage { + viewController.toolbar.doneIconButton.setImage(doneIcon, for: .normal) + } + + if let cancelIcon = styles[.cancelIcon] as? UIImage { + viewController.toolbar.cancelIconButton.setImage(cancelIcon, for: .normal) + } + + if let rotateClockwiseIcon = styles[.rotateClockwiseIcon] as? UIImage { + viewController.toolbar.rotateClockwiseButton?.setImage(rotateClockwiseIcon, for: .normal) + } + + if let rotateCounterclockwiseButtonHidden = styles[.rotateCounterclockwiseButtonHidden] as? Bool { + viewController.toolbar.rotateCounterclockwiseButtonHidden = rotateCounterclockwiseButtonHidden + } + } +} + +extension MediaEditorCropZoomRotate: TOCropViewControllerDelegate { + func cropViewController(_ cropViewController: TOCropViewController, didFinishCancelled cancelled: Bool) { + onCancel() + } + + func cropViewController(_ cropViewController: TOCropViewController, didCropTo image: UIImage, with cropRect: CGRect, angle: Int) { + onFinishEditing(image, cropViewController.actions) + } +} diff --git a/Sources/Capabilities/Crop/TOCropViewController+Ext.swift b/Sources/Capabilities/Crop/TOCropViewController+Ext.swift new file mode 100644 index 0000000..a1ed1d6 --- /dev/null +++ b/Sources/Capabilities/Crop/TOCropViewController+Ext.swift @@ -0,0 +1,39 @@ +import TOCropViewController + +extension TOCropViewController { + // TOCropViewController sometimes resize the image by 1, 2 or 3 points automatically. + // In those cases we're not considering that as a cropping action. + var isCropped: Bool { + return abs(imageSizeDiscardingRotation.width - image.size.width) > 4 || + abs(imageSizeDiscardingRotation.height - image.size.height) > 4 + } + + var imageSizeDiscardingRotation: CGSize { + let imageSize = imageCropFrame.size + + let anglesThatChangesImageSize = [90, 270] + if anglesThatChangesImageSize.contains(angle) { + return CGSize(width: imageSize.height, height: imageSize.width) + } else { + return imageSize + } + } + + var isRotated: Bool { + return angle != 0 + } + + var actions: [MediaEditorOperation] { + var operations: [MediaEditorOperation] = [] + + if isCropped { + operations.append(.crop) + } + + if isRotated { + operations.append(.rotate) + } + + return operations + } +} diff --git a/Sources/Capabilities/MediaEditorCapability.swift b/Sources/Capabilities/MediaEditorCapability.swift new file mode 100644 index 0000000..c122e84 --- /dev/null +++ b/Sources/Capabilities/MediaEditorCapability.swift @@ -0,0 +1,21 @@ +import UIKit + +public protocol MediaEditorCapability { + static var name: String { get } + + static var icon: UIImage { get } + + var image: UIImage { get set } + + var viewController: UIViewController { get } + + var onFinishEditing: (UIImage, [MediaEditorOperation]) -> () { get } + + var onCancel: (() -> ()) { get } + + init(_ image: UIImage, + onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), + onCancel: @escaping () -> ()) + + func apply(styles: MediaEditorStyles) +} diff --git a/Sources/Enums/MediaEditorOperation.swift b/Sources/Enums/MediaEditorOperation.swift new file mode 100644 index 0000000..1e83849 --- /dev/null +++ b/Sources/Enums/MediaEditorOperation.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum MediaEditorOperation { + case crop + case rotate + case other +} diff --git a/Sources/Enums/MediaEditorStyle.swift b/Sources/Enums/MediaEditorStyle.swift new file mode 100644 index 0000000..3f01a87 --- /dev/null +++ b/Sources/Enums/MediaEditorStyle.swift @@ -0,0 +1,20 @@ +import Foundation + +public typealias MediaEditorStyles = [MediaEditorStyle: Any] + +public enum MediaEditorStyle { + case insertLabel + case doneLabel + case doneColor + case cancelLabel + case cancelColor + case resetIcon + case doneIcon + case cancelIcon + case rotateClockwiseIcon + case rotateCounterclockwiseButtonHidden + case loadingLabel + case selectedColor + case errorLoadingImageMessage + case retryIcon +} diff --git a/Sources/Extensions/Bundle+mediaEditor.swift b/Sources/Extensions/Bundle+mediaEditor.swift new file mode 100644 index 0000000..17adab9 --- /dev/null +++ b/Sources/Extensions/Bundle+mediaEditor.swift @@ -0,0 +1,14 @@ +import Foundation + +extension Bundle { + @objc public class var mediaEditor: Bundle { + let defaultBundle = Bundle(for: MediaEditor.self) + // If installed with CocoaPods, resources will be in MediaEditor.bundle + if let bundleURL = defaultBundle.resourceURL, + let resourceBundle = Bundle(url: bundleURL.appendingPathComponent("MediaEditor.bundle")) { + return resourceBundle + } + // Otherwise, the default bundle is used for resources + return defaultBundle + } +} diff --git a/Sources/Extensions/PHAsset+AsyncImage.swift b/Sources/Extensions/PHAsset+AsyncImage.swift new file mode 100644 index 0000000..3c07f17 --- /dev/null +++ b/Sources/Extensions/PHAsset+AsyncImage.swift @@ -0,0 +1,75 @@ +import Photos +import UIKit + +/** +This is an extension to allow PHAsset in Media Editor. +*/ +extension PHAsset: AsyncImage { + /** + PHAsset doesn't provide a thumbnail right away. + It will be requested in the thumbnail() method + */ + public var thumb: UIImage? { + return nil + } + + /** + Keep track of all ongoing image requests so they can be cancelled. + */ + public var requests: [PHImageRequestID] { + get { + return objc_getAssociatedObject(self, &Keys.requests) as? [PHImageRequestID] ?? [] + } + set { + objc_setAssociatedObject(self, &Keys.requests, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + /** + Fetch a thumbnail and then display a better quality one. + */ + public func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) { + let options = PHImageRequestOptions() + options.deliveryMode = .opportunistic + options.version = .current + options.resizeMode = .fast + let requestID = PHImageManager.default().requestImage(for: self, targetSize: CGSize(width: pixelWidth, height: pixelHeight), contentMode: .default, options: options) { image, info in + guard let image = image else { + finishedRetrievingThumbnail(nil) + return + } + + finishedRetrievingThumbnail(image) + } + requests.append(requestID) + } + + /** + Fetch the full quality image. + */ + public func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) { + let options = PHImageRequestOptions() + options.deliveryMode = .highQualityFormat + options.version = .current + let requestID = PHImageManager.default().requestImage(for: self, targetSize: CGSize(width: pixelWidth, height: pixelHeight), contentMode: .default, options: options) { image, info in + guard let image = image else { + finishedRetrievingFullImage(nil) + return + } + + finishedRetrievingFullImage(image) + } + requests.append(requestID) + } + + /** + Cancel all ongoing requests + */ + public func cancel() { + requests.forEach { PHImageManager.default().cancelImageRequest($0) } + } + + private enum Keys { + static var requests = "requests" + } +} diff --git a/Sources/Extensions/UIImage+AsyncImage.swift b/Sources/Extensions/UIImage+AsyncImage.swift new file mode 100644 index 0000000..7b23b3b --- /dev/null +++ b/Sources/Extensions/UIImage+AsyncImage.swift @@ -0,0 +1,13 @@ +import UIKit + +extension UIImage: AsyncImage { + public var thumb: UIImage? { + return self + } + + public func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) {} + + public func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) {} + + public func cancel() {} +} diff --git a/Sources/Info.plist b/Sources/Info.plist new file mode 100644 index 0000000..9bcb244 --- /dev/null +++ b/Sources/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/Sources/Media.xcassets/Contents.json b/Sources/Media.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Sources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Sources/Media.xcassets/gridicons-crop.imageset/Contents.json b/Sources/Media.xcassets/gridicons-crop.imageset/Contents.json new file mode 100644 index 0000000..3f46e9a --- /dev/null +++ b/Sources/Media.xcassets/gridicons-crop.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "gridicons-crop.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Sources/Media.xcassets/gridicons-crop.imageset/gridicons-crop.pdf b/Sources/Media.xcassets/gridicons-crop.imageset/gridicons-crop.pdf new file mode 100644 index 0000000..e093017 Binary files /dev/null and b/Sources/Media.xcassets/gridicons-crop.imageset/gridicons-crop.pdf differ diff --git a/Sources/Media.xcassets/gridicons-cross.imageset/Contents.json b/Sources/Media.xcassets/gridicons-cross.imageset/Contents.json new file mode 100644 index 0000000..d41e32a --- /dev/null +++ b/Sources/Media.xcassets/gridicons-cross.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "gridicons-cross.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Sources/Media.xcassets/gridicons-cross.imageset/gridicons-cross.pdf b/Sources/Media.xcassets/gridicons-cross.imageset/gridicons-cross.pdf new file mode 100644 index 0000000..db4b964 Binary files /dev/null and b/Sources/Media.xcassets/gridicons-cross.imageset/gridicons-cross.pdf differ diff --git a/Sources/MediaEditor.h b/Sources/MediaEditor.h new file mode 100644 index 0000000..8c104f3 --- /dev/null +++ b/Sources/MediaEditor.h @@ -0,0 +1,11 @@ +#import + +//! Project version number for MediaEditor. +FOUNDATION_EXPORT double MediaEditorVersionNumber; + +//! Project version string for MediaEditor. +FOUNDATION_EXPORT const unsigned char MediaEditorVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Sources/MediaEditor.swift b/Sources/MediaEditor.swift new file mode 100644 index 0000000..39343a9 --- /dev/null +++ b/Sources/MediaEditor.swift @@ -0,0 +1,309 @@ +import UIKit + +/** + Since each capability has it's own (or is a) View Controller, the Media Editor + is a Navigation Controller that presents them. + Also, by also being a ViewController, this allows it to be custom presented. + */ +open class MediaEditor: UINavigationController { + static var capabilities: [MediaEditorCapability.Type] = [MediaEditorCropZoomRotate.self] + + var hub: MediaEditorHub = { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.loadViewIfNeeded() + return hub + }() + + var images: [Int: UIImage] = [:] + + var asyncImages: [AsyncImage] = [] + + var editedImagesIndexes: Set = [] + + var onFinishEditing: (([AsyncImage], [MediaEditorOperation]) -> ())? + + var onCancel: (() -> ())? + + public var actions: [MediaEditorOperation] = [] + + var isSingleImageAndCapability: Bool { + return ((asyncImages.count == 1) || (images.count == 1 && asyncImages.count == 0)) && Self.capabilities.count == 1 + } + + private(set) var currentCapability: MediaEditorCapability? + + private var isEditingPlainUIImages = false + + private var lastTappedCapabilityIndex = 0 + + var selectedImageIndex: Int { + return hub.selectedThumbIndex + } + + open var styles: MediaEditorStyles = [:] { + didSet { + currentCapability?.apply(styles: styles) + hub.apply(styles: styles) + } + } + + public init(_ image: UIImage) { + self.images = [0: image] + super.init(rootViewController: hub) + setup() + } + + public init(_ images: [UIImage]) { + self.images = images.enumerated().reduce(into: [:]) { $0[$1.offset] = $1.element } + super.init(rootViewController: hub) + setup() + } + + public init(_ asyncImage: AsyncImage) { + self.asyncImages.append(asyncImage) + super.init(rootViewController: hub) + setup() + } + + public init(_ asyncImages: [AsyncImage]) { + self.asyncImages = asyncImages + super.init(rootViewController: hub) + setup() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + public override func viewDidLoad() { + super.viewDidLoad() + + isEditingPlainUIImages = images.count > 0 + + hub.delegate = self + + modalTransitionStyle = .crossDissolve + modalPresentationStyle = .fullScreen + navigationBar.isHidden = true + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillAppear(animated) + currentCapability = nil + } + + public func edit(from viewController: UIViewController? = nil, onFinishEditing: @escaping ([AsyncImage], [MediaEditorOperation]) -> (), onCancel: (() -> ())? = nil) { + self.onFinishEditing = onFinishEditing + self.onCancel = onCancel + viewController?.present(self, animated: true) + } + + private func setup() { + setupHub() + setupForAsync() + presentIfSingleImageAndCapability() + } + + private func setupHub() { + hub.onCancel = { [weak self] in + self?.cancel() + } + + hub.onDone = { [weak self] in + self?.done() + } + + hub.apply(styles: styles) + + hub.availableThumbs = images + + hub.numberOfThumbs = max(images.count, asyncImages.count) + + hub.capabilities = Self.capabilities.reduce(into: []) { $0.append(($1.name, $1.icon)) } + + hub.apply(styles: styles) + } + + private func setupForAsync() { + asyncImages.enumerated().forEach { offset, asyncImage in + if let thumb = asyncImage.thumb { + thumbnailAvailable(thumb, offset: offset) + } else { + asyncImage.thumbnail(finishedRetrievingThumbnail: { [weak self] thumb in + self?.thumbnailAvailable(thumb, offset: offset) + }) + } + } + + if isSingleImageAndCapability { + hub.disableDoneButton() + capabilityTapped(0) + } + } + + func presentIfSingleImageAndCapability() { + guard isSingleImageAndCapability, let image = images[0], let capabilityEntity = Self.capabilities.first else { + return + } + + present(capability: capabilityEntity, with: image) + } + + private func cancel() { + if currentCapability == nil { + cancelPendingAsyncImagesAndDismiss() + } else if isSingleImageAndCapability { + cancelPendingAsyncImagesAndDismiss() + } else { + dismissCapability() + } + } + + private func done() { + let outputImages = isEditingPlainUIImages ? mapEditedImages() : mapEditedAsyncImages() + onFinishEditing?(outputImages, actions) + dismiss(animated: true) + } + + /* + Map the images hash to an images array preserving the original order, + since Hashes are non-order preserving. + */ + private func mapEditedImages() -> [UIImage] { + return images.enumerated().compactMap { index, _ in images[index] } + } + + private func mapEditedAsyncImages() -> [AsyncImage] { + var editedImages: [AsyncImage] = [] + + for (index, var asyncImage) in asyncImages.enumerated() { + if editedImagesIndexes.contains(index), let editedImage = images[index] { + asyncImage.isEdited = true + asyncImage.editedImage = editedImage + } + editedImages.append(asyncImage) + } + + return editedImages + } + + private func cancelPendingAsyncImagesAndDismiss() { + onCancel?() + asyncImages.forEach { $0.cancel() } + dismiss(animated: true) + } + + private func present(capability capabilityEntity: MediaEditorCapability.Type, with image: UIImage) { + prepareTransition() + + let capability = capabilityEntity.init( + image, + onFinishEditing: { [weak self] image, actions in + self?.finishEditing(image: image, actions: actions) + }, + onCancel: { [weak self] in + self?.cancel() + } + ) + capability.apply(styles: styles) + currentCapability = capability + + pushViewController(capability.viewController, animated: false) + } + + private func finishEditing(image: UIImage, actions: [MediaEditorOperation]) { + images[selectedImageIndex] = image + + self.actions.append(contentsOf: actions) + + if !actions.isEmpty { + editedImagesIndexes.insert(selectedImageIndex) + } + + if isSingleImageAndCapability { + done() + dismiss(animated: true) + } else { + hub.show(image: image, at: selectedImageIndex) + dismissCapability() + } + } + + private func dismissCapability() { + prepareTransition() + popViewController(animated: false) + currentCapability = nil + } + + private func prepareTransition() { + let transition: CATransition = CATransition() + transition.duration = Constants.transitionDuration + transition.type = .fade + view.layer.add(transition, forKey: nil) + } + + private func thumbnailAvailable(_ thumb: UIImage?, offset: Int) { + guard let thumb = thumb else { + return + } + + DispatchQueue.main.async { + self.hub.show(thumb: thumb, at: offset) + } + } + + private func fullImageAvailable(_ image: UIImage?, offset: Int) { + guard let image = image else { + DispatchQueue.main.async { + self.hub.failedToLoad(at: offset) + } + return + } + + self.images[offset] = image + + DispatchQueue.main.async { + self.hub.hideActivityIndicator() + + self.hub.enableDoneButton() + + self.presentIfSingleImageAndCapability() + + self.hub.show(image: image, at: offset) + } + } + + private enum Constants { + static let transitionDuration = 0.3 + } +} + +extension MediaEditor: MediaEditorHubDelegate { + func capabilityTapped(_ index: Int) { + lastTappedCapabilityIndex = index + + if let image = images[selectedImageIndex] { + present(capability: Self.capabilities[index], with: image) + } else { + let offset = selectedImageIndex + hub.loadingImage(at: offset) + asyncImages[selectedImageIndex].full(finishedRetrievingFullImage: { [weak self] image in + DispatchQueue.main.async { + + self?.hub.loadedImage(at: offset) + + self?.fullImageAvailable(image, offset: offset) + + if self?.selectedImageIndex == offset, let image = image { + self?.present(capability: Self.capabilities[index], with: image) + } + + } + }) + } + } + + func retry() { + capabilityTapped(lastTappedCapabilityIndex) + } +} diff --git a/Sources/MediaEditorCapabilityCell.swift b/Sources/MediaEditorCapabilityCell.swift new file mode 100644 index 0000000..3886f1f --- /dev/null +++ b/Sources/MediaEditorCapabilityCell.swift @@ -0,0 +1,11 @@ +import UIKit + +class MediaEditorCapabilityCell: UICollectionViewCell { + @IBOutlet weak var iconButton: UIButton! + + func configure(_ capabilityInfo: (String, UIImage)) { + let (name, icon) = capabilityInfo + iconButton.setImage(icon, for: .normal) + iconButton.accessibilityHint = name + } +} diff --git a/Sources/MediaEditorHub.storyboard b/Sources/MediaEditorHub.storyboard new file mode 100644 index 0000000..a0dd669 --- /dev/null +++ b/Sources/MediaEditorHub.storyboard @@ -0,0 +1,322 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/MediaEditorHub.swift b/Sources/MediaEditorHub.swift new file mode 100644 index 0000000..74a6562 --- /dev/null +++ b/Sources/MediaEditorHub.swift @@ -0,0 +1,376 @@ +import UIKit + +class MediaEditorHub: UIViewController { + + @IBOutlet weak var doneButton: UIButton! + @IBOutlet weak var cancelIconButton: UIButton! + @IBOutlet weak var activityIndicatorView: UIVisualEffectView! + @IBOutlet weak var activityIndicatorLabel: UILabel! + @IBOutlet weak var mainStackView: UIStackView! + @IBOutlet weak var thumbsCollectionView: UICollectionView! + @IBOutlet weak var imagesCollectionView: UICollectionView! + @IBOutlet weak var capabilitiesCollectionView: UICollectionView! + @IBOutlet weak var toolbarHeight: NSLayoutConstraint! + + weak var delegate: MediaEditorHubDelegate? + + var onCancel: (() -> ())? + + var onDone: (() -> ())? + + var numberOfThumbs = 0 { + didSet { + setupToolbar() + } + } + + var capabilities: [(String, UIImage)] = [] { + didSet { + setupCapabilities() + } + } + + var availableThumbs: [Int: UIImage] = [:] + + var availableImages: [Int: UIImage] = [:] + + private(set) var selectedThumbIndex = 0 { + didSet { + highlightSelectedThumb(current: selectedThumbIndex, before: oldValue) + showOrHideActivityIndicatorAndCapabilities() + } + } + + private(set) var isUserScrolling = false + + private var selectedColor: UIColor? + + private var indexesOfImagesBeingLoaded: [Int] = [] + + private var isSingleImage: Bool { + return numberOfThumbs == 1 + } + + private var isSingleCapabilityAndImage: Bool { + isSingleImage && capabilities.count == 1 + } + + private var styles: MediaEditorStyles? + + private var hubDidAppeared = false + + override func viewDidLoad() { + super.viewDidLoad() + thumbsCollectionView.dataSource = self + thumbsCollectionView.delegate = self + capabilitiesCollectionView.dataSource = self + capabilitiesCollectionView.delegate = self + imagesCollectionView.dataSource = self + imagesCollectionView.delegate = self + } + + /// Select the last asset every time the view layout it's subviews until the hub appears. + /// This is needed because of some layout recalculations that happens. + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if !hubDidAppeared { + selectLastAsset() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + hubDidAppeared = true + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + reloadImagesAndReposition() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { _ in + self.reloadImagesAndReposition() + }) + } + + @IBAction func cancel(_ sender: Any) { + onCancel?() + } + + @IBAction func done(_ sender: Any) { + onDone?() + } + + func show(image: UIImage, at index: Int) { + availableImages[index] = image + availableThumbs[index] = image + + let imageCell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell + imageCell?.errorView.isHidden = true + imageCell?.imageView.image = image + + let cell = thumbsCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorThumbCell + cell?.thumbImageView.image = image + + showOrHideActivityIndicatorAndCapabilities() + } + + func show(thumb: UIImage, at index: Int) { + availableThumbs[index] = thumb + + let cell = thumbsCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorThumbCell + cell?.thumbImageView.image = thumb + + let imageCell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell + imageCell?.imageView.image = availableImages[index] ?? thumb + + showOrHideActivityIndicatorAndCapabilities() + } + + func apply(styles: MediaEditorStyles) { + loadViewIfNeeded() + + self.styles = styles + + if let doneLabel = (styles[.insertLabel] ?? styles[.doneLabel]) as? String { + doneButton.setTitle(String(format: doneLabel, "\(numberOfThumbs)"), for: .normal) + } + + if let cancelColor = styles[.cancelColor] as? UIColor { + cancelIconButton.tintColor = cancelColor + } + + if let doneColor = styles[.doneColor] as? UIColor { + doneButton.tintColor = doneColor + } + + if let cancelIcon = styles[.cancelIcon] as? UIImage { + cancelIconButton.setImage(cancelIcon, for: .normal) + } + + if let loadingLabel = styles[.loadingLabel] as? String { + activityIndicatorLabel.text = loadingLabel + } + + if let color = styles[.selectedColor] as? UIColor { + selectedColor = color + } + } + + func showActivityIndicator() { + activityIndicatorView.isHidden = false + } + + func hideActivityIndicator() { + activityIndicatorView.isHidden = true + } + + func disableDoneButton() { + doneButton.isEnabled = false + } + + func enableDoneButton() { + doneButton.isEnabled = true + } + + func loadingImage(at index: Int) { + indexesOfImagesBeingLoaded.append(index) + showOrHideActivityIndicatorAndCapabilities() + } + + func loadedImage(at index: Int) { + indexesOfImagesBeingLoaded = indexesOfImagesBeingLoaded.filter { $0 != index } + showOrHideActivityIndicatorAndCapabilities() + } + + func failedToLoad(at index: Int) { + let cell = imagesCollectionView.cellForItem(at: IndexPath(row: index, section: 0)) as? MediaEditorImageCell + cell?.errorView.isHidden = false + hideActivityIndicator() + } + + private func reloadImagesAndReposition() { + view.layoutIfNeeded() + thumbsCollectionView.reloadData() + imagesCollectionView.reloadData() + thumbsCollectionView.layoutIfNeeded() + thumbsCollectionView.selectItem(at: IndexPath(row: selectedThumbIndex, section: 0), animated: false, scrollPosition: .right) + imagesCollectionView.scrollToItem(at: IndexPath(row: selectedThumbIndex, section: 0), at: .right, animated: false) + } + + private func setupToolbar() { + toolbarHeight.constant = isSingleImage ? Constants.doneButtonHeight : Constants.thumbHeight + thumbsCollectionView.isHidden = isSingleImage ? true : false + } + + private func highlightSelectedThumb(current: Int, before: Int) { + let current = thumbsCollectionView.cellForItem(at: IndexPath(row: current, section: 0)) as? MediaEditorThumbCell + let before = thumbsCollectionView.cellForItem(at: IndexPath(row: before, section: 0)) as? MediaEditorThumbCell + before?.hideBorder() + current?.showBorder() + } + + private func showOrHideActivityIndicatorAndCapabilities() { + let imageAvailable = availableThumbs[selectedThumbIndex] ?? availableImages[selectedThumbIndex] + + let isBeingLoaded = imageAvailable == nil || indexesOfImagesBeingLoaded.contains(selectedThumbIndex) + + if isBeingLoaded { + showActivityIndicator() + disableCapabilities() + } else { + hideActivityIndicator() + enableCapabilities() + } + } + + private func disableCapabilities() { + capabilitiesCollectionView.isUserInteractionEnabled = false + capabilitiesCollectionView.layer.opacity = 0.5 + } + + private func enableCapabilities() { + capabilitiesCollectionView.isUserInteractionEnabled = true + capabilitiesCollectionView.layer.opacity = 1 + } + + private func setupCapabilities() { + capabilitiesCollectionView.isHidden = isSingleCapabilityAndImage ? true : false + capabilitiesCollectionView.reloadData() + } + + private func selectLastAsset() { + DispatchQueue.main.async { + self.selectedThumbIndex = self.numberOfThumbs - 1 + self.imagesCollectionView.scrollToItem(at: IndexPath(row: self.selectedThumbIndex, section: 0), at: .right, animated: false) + self.thumbsCollectionView.scrollToItem(at: IndexPath(row: self.selectedThumbIndex, section: 0), at: .right, animated: false) + } + } + + static func initialize() -> MediaEditorHub { + return UIStoryboard(name: "MediaEditorHub", bundle: Bundle(for: MediaEditorHub.self)).instantiateViewController(withIdentifier: "hubViewController") as! MediaEditorHub + } + + private enum Constants { + static var thumbCellIdentifier = "thumbCell" + static var imageCellIdentifier = "imageCell" + static var capabCellIdentifier = "capabilityCell" + static var thumbHeight: CGFloat = 64 + static var doneButtonHeight: CGFloat = 44 + } +} + +// MARK: - UICollectionViewDataSource + +extension MediaEditorHub: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return collectionView == capabilitiesCollectionView ? capabilities.count : numberOfThumbs + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + if collectionView == thumbsCollectionView { + return cellForThumbsCollectionView(cellForItemAt: indexPath) + } else if collectionView == imagesCollectionView { + return cellForImagesCollectionView(cellForItemAt: indexPath) + } + + return cellForCapabilityCollectionView(cellForItemAt: indexPath) + } + + private func cellForThumbsCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = thumbsCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.thumbCellIdentifier, for: indexPath) + + if let thumbCell = cell as? MediaEditorThumbCell { + thumbCell.thumbImageView.image = availableThumbs[indexPath.row] + indexPath.row == selectedThumbIndex ? thumbCell.showBorder(color: selectedColor) : thumbCell.hideBorder() + } + + return cell + } + + private func cellForImagesCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = imagesCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.imageCellIdentifier, for: indexPath) + + if let imageCell = cell as? MediaEditorImageCell { + imageCell.imageView.image = availableImages[indexPath.row] ?? availableThumbs[indexPath.row] + imageCell.errorView.isHidden = true + imageCell.apply(styles: styles) + imageCell.delegate = delegate + } + + showOrHideActivityIndicatorAndCapabilities() + + return cell + } + + private func cellForCapabilityCollectionView(cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = capabilitiesCollectionView.dequeueReusableCell(withReuseIdentifier: Constants.capabCellIdentifier, for: indexPath) + + if let capabilityCell = cell as? MediaEditorCapabilityCell { + capabilityCell.configure(capabilities[indexPath.row]) + } + + return cell + } +} + +// MARK: - UICollectionViewDelegateFlowLayout + +extension MediaEditorHub: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + if collectionView == imagesCollectionView { + return CGSize(width: imagesCollectionView.frame.width, height: imagesCollectionView.frame.height) + } + + return CGSize(width: 44, height: 44) + } +} + +// MARK: - UICollectionViewDelegate + +extension MediaEditorHub: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if collectionView == thumbsCollectionView { + selectedThumbIndex = indexPath.row + imagesCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) + } else if collectionView == capabilitiesCollectionView { + delegate?.capabilityTapped(indexPath.row) + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView == imagesCollectionView, isUserScrolling else { + return + } + + let imageIndexBasedOnScroll = Int(round(scrollView.bounds.origin.x / imagesCollectionView.frame.width)) + + thumbsCollectionView.selectItem(at: IndexPath(row: imageIndexBasedOnScroll, section: 0), animated: true, scrollPosition: .right) + selectedThumbIndex = imageIndexBasedOnScroll + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard scrollView == imagesCollectionView else { + return + } + + isUserScrolling = true + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + guard scrollView == imagesCollectionView else { + return + } + + isUserScrolling = false + } +} + +protocol MediaEditorHubDelegate: class { + func capabilityTapped(_ index: Int) + func retry() +} diff --git a/Sources/MediaEditorImageCell.swift b/Sources/MediaEditorImageCell.swift new file mode 100644 index 0000000..e9c9740 --- /dev/null +++ b/Sources/MediaEditorImageCell.swift @@ -0,0 +1,30 @@ +import UIKit + +class MediaEditorImageCell: UICollectionViewCell { + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var errorView: UIView! + @IBOutlet weak var errorLabel: UILabel! + @IBOutlet weak var retryButton: UIButton! + + weak var delegate: MediaEditorHubDelegate? + + func apply(styles: MediaEditorStyles?) { + guard let styles = styles else { + return + } + + if let errorLoadingImageMessage = styles[.errorLoadingImageMessage] as? String { + errorLabel.text = errorLoadingImageMessage + } + + if let retryIcon = styles[.retryIcon] as? UIImage { + retryButton.setImage(retryIcon, for: .normal) + } + + } + + @IBAction func retry(_ sender: Any) { + delegate?.retry() + } + +} diff --git a/Sources/MediaEditorThumbCell.swift b/Sources/MediaEditorThumbCell.swift new file mode 100644 index 0000000..9fc11a5 --- /dev/null +++ b/Sources/MediaEditorThumbCell.swift @@ -0,0 +1,18 @@ +import UIKit + +class MediaEditorThumbCell: UICollectionViewCell { + @IBOutlet weak var thumbImageView: UIImageView! + + func showBorder(color: UIColor? = nil) { + layer.borderWidth = 5 + layer.borderColor = color?.cgColor ?? Constant.defaultSelectedColor + } + + func hideBorder() { + layer.borderWidth = 0 + } + + private enum Constant { + static var defaultSelectedColor = UIColor(red: 0.133, green: 0.443, blue: 0.694, alpha: 1).cgColor + } +} diff --git a/Tests/Capabilities/Crop/MediaEditorCropZoomRotateTests.swift b/Tests/Capabilities/Crop/MediaEditorCropZoomRotateTests.swift new file mode 100644 index 0000000..4401e79 --- /dev/null +++ b/Tests/Capabilities/Crop/MediaEditorCropZoomRotateTests.swift @@ -0,0 +1,65 @@ +import XCTest +import TOCropViewController +import Nimble + +@testable import MediaEditor + +class MediaEditorCropZoomRotateTests: XCTestCase { + + private let image = UIImage() + + func testIsAMediaEditorCapability() { + let mediaEditorCrop = MediaEditorCropZoomRotate(image, onFinishEditing: { _, _ in }, onCancel: {}) + + expect(mediaEditorCrop).to(beAKindOf(MediaEditorCapability.self)) + } + + func testDoNotHideNavigation() { + let mediaEditorCrop = MediaEditorCropZoomRotate(image, onFinishEditing: { _, _ in }, onCancel: {}) + + let viewController = mediaEditorCrop.viewController as? TOCropViewController + + expect(viewController?.hidesNavigationBar).to(beFalse()) + } + + func testOnDidCropToRectCallOnFinishEditing() { + var onFinishEditingCalled = false + let mediaEditorCrop = MediaEditorCropZoomRotate( + image, + onFinishEditing: { _, _ in + onFinishEditingCalled = true + }, + onCancel: {}) + let viewController = mediaEditorCrop.viewController as? TOCropViewController + + viewController?.delegate?.cropViewController?(viewController!, didCropTo: image, with: .zero, angle: 0) + + expect(onFinishEditingCalled).to(beTrue()) + } + + func testOnDidFinishCancelledCall() { + var onCancelCalled = false + let mediaEditorCrop = MediaEditorCropZoomRotate( + image, + onFinishEditing: { _, _ in }, + onCancel: { + onCancelCalled = true + } + ) + let viewController = mediaEditorCrop.viewController as? TOCropViewController + + viewController?.delegate?.cropViewController?(viewController!, didFinishCancelled: true) + + expect(onCancelCalled).to(beTrue()) + } + + func testHideRotateCounterclockwiseButton() { + let mediaEditorCrop = MediaEditorCropZoomRotate(image, onFinishEditing: { _, _ in }, onCancel: {}) + + mediaEditorCrop.apply(styles: [.rotateCounterclockwiseButtonHidden: true]) + + let viewController = mediaEditorCrop.viewController as? TOCropViewController + expect(viewController?.toolbar.rotateCounterclockwiseButtonHidden).to(beTrue()) + } + +} diff --git a/Tests/Extensions/UIApplication+topWindow.swift b/Tests/Extensions/UIApplication+topWindow.swift new file mode 100644 index 0000000..beeefa3 --- /dev/null +++ b/Tests/Extensions/UIApplication+topWindow.swift @@ -0,0 +1,7 @@ +import UIKit + +extension UIApplication { + var topWindow: UIWindow? { + return windows.first + } +} diff --git a/Tests/Extensions/UIImage+color.swift b/Tests/Extensions/UIImage+color.swift new file mode 100644 index 0000000..c8b28c7 --- /dev/null +++ b/Tests/Extensions/UIImage+color.swift @@ -0,0 +1,21 @@ +import UIKit + +extension UIImage { + + /** + Returns an UIImage with a specified background color. + - parameter color: The color of the background + */ + convenience init(color: UIColor) { + let rect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) + UIGraphicsBeginImageContext(rect.size); + let context:CGContext = UIGraphicsGetCurrentContext()!; + context.setFillColor(color.cgColor); + context.fill(rect) + + let image:UIImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + + self.init(ciImage: CIImage(image: image)!) + } +} diff --git a/Tests/Info.plist b/Tests/Info.plist new file mode 100644 index 0000000..64d65ca --- /dev/null +++ b/Tests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Tests/MediaEditorHubTests.swift b/Tests/MediaEditorHubTests.swift new file mode 100644 index 0000000..cbab77d --- /dev/null +++ b/Tests/MediaEditorHubTests.swift @@ -0,0 +1,182 @@ +import XCTest +import Nimble + +@testable import MediaEditor + +class MediaEditorHubTests: XCTestCase { + + func testInitializeFromStoryboard() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + + expect(hub).toNot(beNil()) + } + + func testShowImage() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + _ = hub.view + let image = UIImage() + + hub.show(image: image, at: 0) + + let firstImageCell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell + expect(firstImageCell?.imageView.image).to(equal(image)) + } + + func testTappingCancelButtonCallsOnCancel() { + var didCallOnCancel = false + let hub: MediaEditorHub = MediaEditorHub.initialize() + _ = hub.view + hub.onCancel = { + didCallOnCancel = true + } + + hub.cancelIconButton.sendActions(for: .touchUpInside) + + expect(didCallOnCancel).to(beTrue()) + } + + func testTappingDoneButtonCallsOnDone() { + var didCallOnDone = false + let hub: MediaEditorHub = MediaEditorHub.initialize() + _ = hub.view + hub.onDone = { + didCallOnDone = true + } + + hub.doneButton.sendActions(for: .touchUpInside) + + expect(didCallOnDone).to(beTrue()) + } + + func testApplyLoadingLabel() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + + hub.apply(styles: [.loadingLabel: "foo"]) + + expect(hub.activityIndicatorLabel.text).to(equal("foo")) + } + + func testApplyErrorLoadingImageLabelIntoImageCell() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage()] + + hub.apply(styles: [.errorLoadingImageMessage: "error loading image"]) + + let cell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell + expect(cell?.errorLabel.text).to(equal("error loading image")) + } + + + func testShowButtonWithTheCapabilityIcon() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.loadViewIfNeeded() + let icon = UIImage() + + hub.capabilities = [("Foo", icon)] + + let capabilityCell = hub.collectionView(hub.capabilitiesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorCapabilityCell + expect(capabilityCell?.iconButton.imageView?.image).to(equal(icon)) + } + + func testCallsDelegateWhenCapabilityIsTapped() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.loadViewIfNeeded() + let delegateMock = MediaEditorHubDelegateMock() + hub.delegate = delegateMock + + hub.collectionView(hub.capabilitiesCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) + + expect(delegateMock.didCallCapabilityTappedWithIndex).to(equal(1)) + } + + func testShowActivityIndicatorWhenLoadingAnImage() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.loadViewIfNeeded() + + hub.loadingImage(at: 1) + + expect(hub.activityIndicatorView.isHidden).to(beFalse()) + } + + func testDoNotShowActivityIndicatorIfImageIsNotBeingLoaded() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + hub.loadingImage(at: 0) + + hub.collectionView(hub.thumbsCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) + + expect(hub.activityIndicatorView.isHidden).to(beTrue()) + } + + func testShowActivityIndicatorWhenSwipingToAnImageBeingLoaded() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + hub.loadingImage(at: 1) + hub.loadingImage(at: 0) + + hub.collectionView(hub.thumbsCollectionView, didSelectItemAt: IndexPath(row: 1, section: 0)) + + expect(hub.activityIndicatorView.isHidden).to(beFalse()) + } + + func testDisableCapabilitiesWhenImageIsBeingLoaded() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + + hub.loadingImage(at: 0) + + expect(hub.capabilitiesCollectionView.isUserInteractionEnabled).to(beFalse()) + } + + func testHideActivityIndicatorWhenImageIsLoaded() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + hub.loadingImage(at: 0) + + hub.loadedImage(at: 0) + + expect(hub.activityIndicatorView.isHidden).to(beTrue()) + } + + func testEnableCapabilitiesWhenImageIsLoaded() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + hub.loadingImage(at: 0) + + hub.loadedImage(at: 0) + + expect(hub.capabilitiesCollectionView.isUserInteractionEnabled).to(beTrue()) + } + + func testCallRetryDelegate() { + let hub: MediaEditorHub = MediaEditorHub.initialize() + hub.availableThumbs = [0: UIImage(), 1: UIImage()] + hub.loadViewIfNeeded() + let delegateMock = MediaEditorHubDelegateMock() + hub.delegate = delegateMock + + let cell = hub.collectionView(hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell + cell?.retryButton.sendActions(for: .touchUpInside) + + expect(delegateMock.didCallRetry).to(beTrue()) + } + +} + +private class MediaEditorHubDelegateMock: MediaEditorHubDelegate { + var didCallCapabilityTappedWithIndex: Int? + var didCallRetry = false + + func capabilityTapped(_ index: Int) { + didCallCapabilityTappedWithIndex = index + } + + func retry() { + didCallRetry = true + } +} diff --git a/Tests/MediaEditorTests.swift b/Tests/MediaEditorTests.swift new file mode 100644 index 0000000..b825cc9 --- /dev/null +++ b/Tests/MediaEditorTests.swift @@ -0,0 +1,566 @@ +import XCTest +import TOCropViewController +import Nimble + +@testable import MediaEditor + +class MediaEditorTests: XCTestCase { + private let image = UIImage() + + override class func setUp() { + super.setUp() + MediaEditor.capabilities = [MockCapability.self] + } + + func testNavigationBarIsHidden() { + let mediaEditor = MediaEditor(image) + + expect(mediaEditor.navigationBar.isHidden).to(beTrue()) + } + + func testModalTransitionStyle() { + let mediaEditor = MediaEditor(image) + + expect(mediaEditor.modalTransitionStyle).to(equal(.crossDissolve)) + } + + func testModalPresentationStyle() { + let mediaEditor = MediaEditor(image) + + expect(mediaEditor.modalPresentationStyle).to(equal(.fullScreen)) + } + + func testHubDelegate() { + let mediaEditor = MediaEditor(image) + + let hubDelegate = mediaEditor.hub.delegate as? MediaEditor + + expect(hubDelegate).to(equal(mediaEditor)) + } + + func testGivesTheListOfCapabilitiesIconsAndNames() { + let mediaEditor = MediaEditor(image) + + expect(mediaEditor.hub.capabilities.count).to(equal(1)) + } + + func testSettingStylesChangingTheCurrentShownCapability() { + let mediaEditor = MediaEditor(image) + + mediaEditor.styles = [.doneLabel: "foo"] + + let currentCapability = mediaEditor.currentCapability as? MockCapability + expect(currentCapability?.applyCalled).to(beTrue()) + } + + func testEditPresentsFromTheGivenViewController() { + let viewController = UIViewControllerMock() + let mediaEditor = MediaEditor(image) + + mediaEditor.edit(from: viewController, onFinishEditing: { _, _ in }) + + expect(viewController.didCallPresentWith).to(equal(mediaEditor)) + } + + // WHEN: One single image + one single capability + + func testShowTheCapabilityRightAway() { + let mediaEditor = MediaEditor(image) + + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.currentCapability?.viewController)) + } + + func testWhenCancelingDismissTheMediaEditor() { + let viewController = UIViewController() + UIApplication.shared.topWindow?.addSubview(viewController.view) + let mediaEditor = MediaEditor(image) + viewController.present(mediaEditor, animated: false) + + mediaEditor.currentCapability?.onCancel() + + expect(viewController.presentedViewController).toEventually(beNil()) + } + + func testWhenFinishEditingCallOnFinishEditing() { + var didCallOnFinishEditing = false + let mediaEditor = MediaEditor(image) + mediaEditor.onFinishEditing = { _, _ in + didCallOnFinishEditing = true + } + + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + expect(didCallOnFinishEditing).to(beTrue()) + } + + func testWhenFinishEditingKeepRecordOfTheActions() { + let mediaEditor = MediaEditor(image) + mediaEditor.actions = [.crop] + mediaEditor.onFinishEditing = { _, _ in } + + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + expect(mediaEditor.actions).to(equal([.crop, .rotate])) + } + + func testWhenFinishEditingImagesReturnTheImages() { + var returnedImages: [UIImage] = [] + let mediaEditor = MediaEditor(image) + mediaEditor.onFinishEditing = { images, _ in + returnedImages = images as! [UIImage] + } + + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + expect(returnedImages).to(equal([image])) + } + + // WHEN: Async image + one single capability + + func testRequestThumbAndFullImageQuality() { + let asyncImage = AsyncImageMock() + + _ = MediaEditor(asyncImage) + + expect(asyncImage.didCallThumbnail).to(beTrue()) + expect(asyncImage.didCallFull).to(beTrue()) + } + + func testIfThumbnailIsAvailableShowItInHub() { + let asyncImage = AsyncImageMock() + asyncImage.thumb = UIImage() + + let mediaEditor = MediaEditor(asyncImage) + UIApplication.shared.topWindow?.addSubview(mediaEditor.view) + + expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(asyncImage.thumb)) + } + + func testDoNotRequestThumbnailIfOneIsGiven() { + let asyncImage = AsyncImageMock() + asyncImage.thumb = UIImage() + + _ = MediaEditor(asyncImage) + + expect(asyncImage.didCallFull).to(beTrue()) + expect(asyncImage.didCallThumbnail).to(beFalse()) + } + + func testShowActivityIndicatorWhenLoadingImage() { + let asyncImage = AsyncImageMock() + asyncImage.thumb = UIImage() + + let mediaEditor = MediaEditor(asyncImage) + + expect(mediaEditor.hub.activityIndicatorView.isHidden).to(beFalse()) + } + + func testWhenThumbnailIsAvailableShowItInHub() { + let asyncImage = AsyncImageMock() + let thumb = UIImage() + let mediaEditor = MediaEditor(asyncImage) + UIApplication.shared.topWindow?.addSubview(mediaEditor.view) + + asyncImage.simulate(thumbHasBeenDownloaded: thumb) + + expect((mediaEditor.hub.collectionView(mediaEditor.hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(thumb)) + } + + func testWhenFullImageIsAvailableShowItInHub() { + let asyncImage = AsyncImageMock() + let fullImage = UIImage() + let mediaEditor = MediaEditor(asyncImage) + UIApplication.shared.topWindow?.addSubview(mediaEditor.view) + + asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) + + expect((mediaEditor.hub.collectionView(mediaEditor.hub.imagesCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(fullImage)) + } + + func testWhenFullImageIsAvailableHideActivityIndicatorView() { + let asyncImage = AsyncImageMock() + let fullImage = UIImage() + let mediaEditor = MediaEditor(asyncImage) + UIApplication.shared.topWindow?.addSubview(mediaEditor.view) + + asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) + + expect(mediaEditor.hub.activityIndicatorView.isHidden).toEventually(beTrue()) + } + + func testPresentCapabilityAfterFullImageIsAvailable() { + let asyncImage = AsyncImageMock() + let fullImage = UIImage() + let mediaEditor = MediaEditor(asyncImage) + + asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) + + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.currentCapability?.viewController)) + } + + func testCallCancelOnAsyncImageWhenUserCancel() { + let asyncImage = AsyncImageMock() + let mediaEditor = MediaEditor(asyncImage) + + mediaEditor.hub.cancelIconButton.sendActions(for: .touchUpInside) + + expect(asyncImage.didCallCancel).to(beTrue()) + } + + func testDoNotDisplayThumbnailIfFullImageIsAlreadyVisible() { + let asyncImage = AsyncImageMock() + let fullImage = UIImage(color: .white) + let thumbImage = UIImage(color: .black) + let mediaEditor = MediaEditor(asyncImage) + UIApplication.shared.topWindow?.addSubview(mediaEditor.view) + + asyncImage.simulate(fullImageHasBeenDownloaded: fullImage) + asyncImage.simulate(thumbHasBeenDownloaded: thumbImage) + + expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventually(equal(fullImage)) + expect((mediaEditor.hub.imagesCollectionView.cellForItem(at: IndexPath(row: 0, section: 0)) as? MediaEditorImageCell)?.imageView.image).toEventuallyNot(equal(thumbImage)) + } + + func testHidesThumbsToolbar() { + let asyncImage = AsyncImageMock() + + let mediaEditor = MediaEditor(asyncImage) + + expect(mediaEditor.hub.thumbsCollectionView.isHidden).to(beTrue()) + } + + func testWhenFinishEditingAsyncImageReturnTheAsyncImage() { + // Given + var returnedImages: [AsyncImage] = [] + let asyncImage = AsyncImageMock() + let mediaEditor = MediaEditor(asyncImage) + asyncImage.simulate(fullImageHasBeenDownloaded: UIImage()) + mediaEditor.onFinishEditing = { images, _ in + returnedImages = images + } + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) // Wait capability appear + + // When + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + // Then + expect(returnedImages.first?.isEdited).to(beTrue()) + expect(returnedImages.first?.editedImage).to(equal(image)) + } + + + func testDisableDoneButtonWhileLoading() { + let asyncImage = AsyncImageMock() + + let mediaEditor = MediaEditor(asyncImage) + + expect(mediaEditor.hub.doneButton.isEnabled).to(beFalse()) + } + + func testEnableDoneButtonOnceImageIsLoaded() { + let asyncImage = AsyncImageMock() + let mediaEditor = MediaEditor(asyncImage) + + asyncImage.simulate(fullImageHasBeenDownloaded: image) + + expect(mediaEditor.hub.doneButton.isEnabled).toEventually(beTrue()) + } + + // WHEN: Multiple images + one single capability + + func testShowThumbs() { + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + + let mediaEditor = MediaEditor([whiteImage, blackImage]) + + let firstThumb = mediaEditor.hub.collectionView(mediaEditor.hub.thumbsCollectionView, cellForItemAt: IndexPath(row: 0, section: 0)) as? MediaEditorThumbCell + let secondThumb = mediaEditor.hub.collectionView(mediaEditor.hub.thumbsCollectionView, cellForItemAt: IndexPath(row: 1, section: 0)) as? MediaEditorThumbCell + expect(firstThumb?.thumbImageView.image).to(equal(whiteImage)) + expect(secondThumb?.thumbImageView.image).to(equal(blackImage)) + } + + func testPresentsTheHub() { + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + + let mediaEditor = MediaEditor([whiteImage, blackImage]) + + expect(mediaEditor.currentCapability).to(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) + } + + func testTappingACapabilityPresentsIt() { + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + let mediaEditor = MediaEditor([whiteImage, blackImage]) + + mediaEditor.capabilityTapped(0) + + expect(mediaEditor.currentCapability).toNot(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.currentCapability?.viewController)) + } + + func testCallingOnCancelWhenShowingACapabilityGoesBackToHub() { + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + let mediaEditor = MediaEditor([whiteImage, blackImage]) + mediaEditor.capabilityTapped(0) + + mediaEditor.currentCapability?.onCancel() + + expect(mediaEditor.currentCapability).to(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) + } + + func testCallingOnFinishWhenShowingACapabilityUpdatesTheImage() { + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + let editedImage = UIImage() + let mediaEditor = MediaEditor([whiteImage, blackImage]) + mediaEditor.capabilityTapped(0) + + mediaEditor.currentCapability?.onFinishEditing(editedImage, [.crop]) + + expect(mediaEditor.images[0]).to(equal(editedImage)) + expect(mediaEditor.hub.availableImages[0]).to(equal(editedImage)) + expect(mediaEditor.hub.availableThumbs[0]).to(equal(editedImage)) + } + + func testWhenCancelingDismissTheCapabilityAndGoesBackToHub() { + let viewController = UIViewController() + UIApplication.shared.topWindow?.addSubview(viewController.view) + let whiteImage = UIImage(color: .white) + let blackImage = UIImage(color: .black) + let mediaEditor = MediaEditor([whiteImage, blackImage]) + viewController.present(mediaEditor, animated: false) + mediaEditor.capabilityTapped(0) + + mediaEditor.currentCapability?.onCancel() + + expect(mediaEditor.visibleViewController).toEventually(equal(mediaEditor.hub)) + } + + func testWhenFinishEditingMultipleImagesReturnAllTheImages() { + var returnedImages: [UIImage] = [] + let editedImage = UIImage(color: .black) + let mediaEditor = MediaEditor([image, image]) + mediaEditor.onFinishEditing = { images, _ in + returnedImages = images as! [UIImage] + } + mediaEditor.capabilityTapped(0) + mediaEditor.currentCapability?.onFinishEditing(editedImage, [.rotate]) + + mediaEditor.hub.doneButton.sendActions(for: .touchUpInside) + + expect(returnedImages).to(equal([editedImage, image])) + } + + func testWhenCancelEditingMultipleImagesCallOnCancel() { + var didCallOnCancel = false + let mediaEditor = MediaEditor([image, image]) + mediaEditor.onCancel = { + didCallOnCancel = true + } + + mediaEditor.hub.cancelIconButton.sendActions(for: .touchUpInside) + + expect(didCallOnCancel).to(beTrue()) + } + + // WHEN: Multiple async images + one single capability + + func testShowThumbsToolbar() { + let asyncImages = [AsyncImageMock(), AsyncImageMock()] + + let mediaEditor = MediaEditor(asyncImages) + + expect(mediaEditor.hub.thumbsCollectionView.isHidden).to(beFalse()) + } + + func testWhenGivenMultipleAsyncImagesPresentsTheHub() { + let asyncImages = [AsyncImageMock(), AsyncImageMock()] + + let mediaEditor = MediaEditor(asyncImages) + + expect(mediaEditor.currentCapability).to(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) + } + + func testTappingACapabilityDoesntPresentItRightAway() { + let asyncImages = [AsyncImageMock(), AsyncImageMock()] + let mediaEditor = MediaEditor(asyncImages) + + mediaEditor.capabilityTapped(0) + + expect(mediaEditor.currentCapability).to(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.hub)) + } + + func testTappingACapabilityStartsTheRequestForTheFullImage() { + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + + mediaEditor.capabilityTapped(0) + + expect(firstImage.didCallFull).to(beTrue()) + } + + func testWhenTheFullImageIsAvailableShowTheCapability() { + let fullImage = UIImage() + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + mediaEditor.capabilityTapped(0) + + firstImage.simulate(fullImageHasBeenDownloaded: fullImage) + + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) + expect(mediaEditor.visibleViewController).to(equal(mediaEditor.currentCapability?.viewController)) + } + + func testWhenTheFullImageIsAvailableUpdateTheImageReferences() { + let fullImage = UIImage() + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + mediaEditor.capabilityTapped(0) + + firstImage.simulate(fullImageHasBeenDownloaded: fullImage) + + expect(mediaEditor.hub.availableThumbs[0]).toEventually(equal(fullImage)) + expect(mediaEditor.hub.availableImages[0]).to(equal(fullImage)) + expect(mediaEditor.images[0]).to(equal(fullImage)) + } + + func testWhenFinishEditingMultipleAsyncImageReturnAllAsyncImages() { + // Given + var returnedImages: [AsyncImage] = [] + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + mediaEditor.capabilityTapped(0) + firstImage.simulate(fullImageHasBeenDownloaded: UIImage()) + mediaEditor.onFinishEditing = { images, _ in + returnedImages = images + } + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) // Wait capability appear + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + // When + mediaEditor.hub.doneButton.sendActions(for: .touchUpInside) + + // Then + expect(returnedImages.first?.isEdited).to(beTrue()) + expect(returnedImages.first?.editedImage).to(equal(image)) + } + + func testUpdateEditedImagesIndexesAfterEditingAnImage() { + // Given + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + mediaEditor.capabilityTapped(0) + firstImage.simulate(fullImageHasBeenDownloaded: image) + seconImage.simulate(fullImageHasBeenDownloaded: image) + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) // Wait capability appear + + // When + mediaEditor.currentCapability?.onFinishEditing(image, [.rotate]) + + // Then + expect(mediaEditor.editedImagesIndexes).to(equal([0])) + } + + func testRetryAfterAMediaFailsToLoad() { + // Given + let firstImage = AsyncImageMock() + let seconImage = AsyncImageMock() + let mediaEditor = MediaEditor([firstImage, seconImage]) + mediaEditor.capabilityTapped(0) + firstImage.simulateFailure() + + // When + mediaEditor.retry() + firstImage.simulate(fullImageHasBeenDownloaded: image) + + // Then + expect(mediaEditor.currentCapability).toEventuallyNot(beNil()) + } + +} + +class MockCapability: MediaEditorCapability { + static var name = "MockCapability" + + static var icon = UIImage() + + var applyCalled = false + + var image: UIImage + + lazy var viewController: UIViewController = { + return UIViewController() + }() + + var onFinishEditing: (UIImage, [MediaEditorOperation]) -> () + + var onCancel: (() -> ()) + + required init(_ image: UIImage, onFinishEditing: @escaping (UIImage, [MediaEditorOperation]) -> (), onCancel: @escaping () -> ()) { + self.image = image + self.onFinishEditing = onFinishEditing + self.onCancel = onCancel + } + + func apply(styles: MediaEditorStyles) { + applyCalled = true + } +} + +private class AsyncImageMock: AsyncImage { + var didCallThumbnail = false + var didCallFull = false + var didCallCancel = false + + var finishedRetrievingThumbnail: ((UIImage?) -> ())? + var finishedRetrievingFullImage: ((UIImage?) -> ())? + + var thumb: UIImage? + + func thumbnail(finishedRetrievingThumbnail: @escaping (UIImage?) -> ()) { + didCallThumbnail = true + self.finishedRetrievingThumbnail = finishedRetrievingThumbnail + } + + func full(finishedRetrievingFullImage: @escaping (UIImage?) -> ()) { + didCallFull = true + self.finishedRetrievingFullImage = finishedRetrievingFullImage + } + + func cancel() { + didCallCancel = true + } + + func simulate(thumbHasBeenDownloaded thumb: UIImage) { + finishedRetrievingThumbnail?(thumb) + } + + func simulate(fullImageHasBeenDownloaded image: UIImage) { + finishedRetrievingFullImage?(image) + } + + func simulateFailure() { + finishedRetrievingFullImage?(nil) + } +} + +private class UIViewControllerMock: UIViewController { + var didCallPresentWith: UIViewController? + + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + didCallPresentWith = viewControllerToPresent + } +} diff --git a/Tests/Tests.swift b/Tests/Tests.swift new file mode 100644 index 0000000..cb25fc6 --- /dev/null +++ b/Tests/Tests.swift @@ -0,0 +1,33 @@ +// +// Tests.swift +// Tests +// +// Created by Leandro Alonso on 21/01/20. +// Copyright © 2020 Automattic, Inc. All rights reserved. +// + +import XCTest + +class Tests: XCTestCase { + + override func setUp() { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } + +}