diff --git a/Lockbox.xcodeproj/project.pbxproj b/Lockbox.xcodeproj/project.pbxproj index e5ffb4540..53a704790 100644 --- a/Lockbox.xcodeproj/project.pbxproj +++ b/Lockbox.xcodeproj/project.pbxproj @@ -24,11 +24,13 @@ 7AA5456C114B68E64CB540AD /* KeychainManagerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA540ABA174D12707C616B4 /* KeychainManagerSpec.swift */; }; 7AA545A229A2E0E5DBF7461C /* String+Spec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA542B3E1A27FC2B8806363 /* String+Spec.swift */; }; 7AA545ADA952B5C4ECF6D313 /* ItemDetailPresenterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54304F17F52F8B2C3D8BD /* ItemDetailPresenterSpec.swift */; }; + 7AA545D1DBB719E27B782966 /* ItemListDisplayActionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA541A676EEC9A2C4E3909B /* ItemListDisplayActionSpec.swift */; }; 7AA545D6182E36466BC8256A /* CopyConfirmationDisplayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54247F60B5BDC1BA90AD4 /* CopyConfirmationDisplayStore.swift */; }; 7AA545F780DE680A5B0380D3 /* FxAStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54D4248CF43B76BACDBD3 /* FxAStore.swift */; }; 7AA5465C5FE10715567F5E83 /* LoginNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54321DB9E1203005B9F14 /* LoginNavigationController.swift */; }; 7AA54673A04A9E2ED0879ACC /* ItemDetailStoreSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54FCD6855E89F3B156F15 /* ItemDetailStoreSpec.swift */; }; 7AA54684402DC450E11CDAAA /* FxAPresenterSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA544DC0DBB5D050BD54806 /* FxAPresenterSpec.swift */; }; + 7AA5469948A8AE091E024CC7 /* ItemListDisplayStoreSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA544F9FCD826257AD023E7 /* ItemListDisplayStoreSpec.swift */; }; 7AA546A2A19976883EB9CFE4 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54EAE6E8D8E78F37BEA82 /* DataStore.swift */; }; 7AA546E44E75EC392E1AF728 /* RootViewSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54C0FE8643E1EBE3D35B8 /* RootViewSpec.swift */; }; 7AA547376945C001B151491A /* ItemDetailAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54B41C60F04FA585E5B8F /* ItemDetailAction.swift */; }; @@ -37,6 +39,7 @@ 7AA547E098053DFF64A3C0F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7AA54B40696590744930A579 /* Assets.xcassets */; }; 7AA547EF949CA76FC018402A /* ItemListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA546FD22761BBAAD41A800 /* ItemListCell.swift */; }; 7AA547F600FA69AF18A9D55C /* ProfileInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA540BD2A5F50B02F1FE714 /* ProfileInfo.swift */; }; + 7AA548100774B367341D450F /* ItemListDisplayAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54E02364E77393B85C6FE /* ItemListDisplayAction.swift */; }; 7AA548263012DE1D03950DE0 /* RouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54C369D0BF64704C8D5FB /* RouteStore.swift */; }; 7AA54843ADAB6552EDA77C81 /* ProfileInfoSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA546BF73E7AFEB663E5DA8 /* ProfileInfoSpec.swift */; }; 7AA54850D6DE1CD66D8C43AE /* UserInfoActionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54FC2201512347C683533 /* UserInfoActionSpec.swift */; }; @@ -69,6 +72,7 @@ 7AA54DDC2527A59C773FAC3D /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA547418F6E716AEEE385F6 /* Constants.swift */; }; 7AA54DF06588E3B35E010980 /* DispatcherSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54FFBEBCACA7A506F5C16 /* DispatcherSpec.swift */; }; 7AA54E03B01B57D89DE9CF6B /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA5445B47B7A548F0F0E294 /* Action.swift */; }; + 7AA54E0EAF2761904796DB90 /* ItemListDisplayStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54766081BC269C5C33884 /* ItemListDisplayStore.swift */; }; 7AA54E698CA72C6C83804EAC /* CopyActionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54B7943446ED2E302C470 /* CopyActionSpec.swift */; }; 7AA54E83D68AA39FB3A895AF /* RootPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA541C0A078BB02BFD7EB0B /* RootPresenter.swift */; }; 7AA54E848015F62023D3689C /* DataStoreAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA54549204186237036B464 /* DataStoreAction.swift */; }; @@ -189,6 +193,7 @@ 7AA540BD2A5F50B02F1FE714 /* ProfileInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileInfo.swift; sourceTree = ""; }; 7AA54146E0C9269A0CE8D53A /* UserInfoStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoStore.swift; sourceTree = ""; }; 7AA541A2CBD1A142812687CE /* Data+Spec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Data+Spec.swift"; sourceTree = ""; }; + 7AA541A676EEC9A2C4E3909B /* ItemListDisplayActionSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisplayActionSpec.swift; sourceTree = ""; }; 7AA541C0A078BB02BFD7EB0B /* RootPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RootPresenter.swift; sourceTree = ""; }; 7AA54204B5AFD9672649ACF0 /* LoginNavigationControllerSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginNavigationControllerSpec.swift; sourceTree = ""; }; 7AA5420714A76D776F035603 /* buddybuild_prebuild.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = buddybuild_prebuild.sh; sourceTree = ""; }; @@ -209,6 +214,7 @@ 7AA5445C1077C84873E7CB1C /* Parser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; 7AA544DC0DBB5D050BD54806 /* FxAPresenterSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FxAPresenterSpec.swift; sourceTree = ""; }; 7AA544EC2E10034239FCB256 /* OAuthInfoSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OAuthInfoSpec.swift; sourceTree = ""; }; + 7AA544F9FCD826257AD023E7 /* ItemListDisplayStoreSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisplayStoreSpec.swift; sourceTree = ""; }; 7AA5450E45A4DB17626F4684 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; 7AA54549204186237036B464 /* DataStoreAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataStoreAction.swift; sourceTree = ""; }; 7AA5455D02519DBA3D3711A9 /* OAuthInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OAuthInfo.swift; sourceTree = ""; }; @@ -224,6 +230,7 @@ 7AA546FD320F8F1431F20092 /* codecov.yml */ = {isa = PBXFileReference; lastKnownFileType = file.yml; path = codecov.yml; sourceTree = ""; }; 7AA547106248ADBADAF76239 /* ItemDetailCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemDetailCell.swift; sourceTree = ""; }; 7AA547418F6E716AEEE385F6 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; + 7AA54766081BC269C5C33884 /* ItemListDisplayStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisplayStore.swift; sourceTree = ""; }; 7AA547CE3404CC8123CA455D /* Observable+Spec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Observable+Spec.swift"; sourceTree = ""; }; 7AA547E5549B49D4E0D0368D /* ItemEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemEntry.swift; sourceTree = ""; }; 7AA548CFC3319394489DD10E /* FxAPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FxAPresenter.swift; sourceTree = ""; }; @@ -247,6 +254,7 @@ 7AA54CDEA56C47263160AD0E /* Dispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Dispatcher.swift; sourceTree = ""; }; 7AA54D4248CF43B76BACDBD3 /* FxAStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FxAStore.swift; sourceTree = ""; }; 7AA54D4E439D76B910034E2A /* ErrorAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorAction.swift; sourceTree = ""; }; + 7AA54E02364E77393B85C6FE /* ItemListDisplayAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemListDisplayAction.swift; sourceTree = ""; }; 7AA54E126924935F1ADF4E8E /* ItemDetailStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemDetailStore.swift; sourceTree = ""; }; 7AA54E38ACC2FDBBF700D5E1 /* RouteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RouteAction.swift; sourceTree = ""; }; 7AA54E826632AFFBA1677E5E /* UserInfoStoreSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserInfoStoreSpec.swift; sourceTree = ""; }; @@ -417,6 +425,7 @@ 7AA54146E0C9269A0CE8D53A /* UserInfoStore.swift */, 7AA54E126924935F1ADF4E8E /* ItemDetailStore.swift */, 7AA54247F60B5BDC1BA90AD4 /* CopyConfirmationDisplayStore.swift */, + 7AA54766081BC269C5C33884 /* ItemListDisplayStore.swift */, ); path = Store; sourceTree = ""; @@ -451,6 +460,7 @@ 7AA5492DA3988625D1DAB5BD /* UserInfoAction.swift */, 7AA54B41C60F04FA585E5B8F /* ItemDetailAction.swift */, 7AA54575C8FE51664E45ACD8 /* CopyAction.swift */, + 7AA54E02364E77393B85C6FE /* ItemListDisplayAction.swift */, ); path = Action; sourceTree = ""; @@ -585,6 +595,8 @@ D831FEE3205C518400EAE19A /* SettingsPresenterSpec.swift */, D831FEE6205F3D5000EAE19A /* SettingsViewSpec.swift */, 7AA545A802C0592A820AD230 /* Date+Spec.swift */, + 7AA544F9FCD826257AD023E7 /* ItemListDisplayStoreSpec.swift */, + 7AA541A676EEC9A2C4E3909B /* ItemListDisplayActionSpec.swift */, 7AA54FC2201512347C683533 /* UserInfoActionSpec.swift */, D8D029252062F46600CC01C6 /* MainNavigationControllerSpec.swift */, ); @@ -813,6 +825,8 @@ 7AA54E698CA72C6C83804EAC /* CopyActionSpec.swift in Sources */, 7AA54EEAB64EF8D9A7F48739 /* CopyConfirmationDisplayStoreSpec.swift in Sources */, 7AA54A03CA11221087E4F9F8 /* Date+Spec.swift in Sources */, + 7AA5469948A8AE091E024CC7 /* ItemListDisplayStoreSpec.swift in Sources */, + 7AA545D1DBB719E27B782966 /* ItemListDisplayActionSpec.swift in Sources */, 7AA54850D6DE1CD66D8C43AE /* UserInfoActionSpec.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -872,6 +886,8 @@ 7AA54EC7A9CA9BBD57AFEAF9 /* FilterCell.swift in Sources */, 7AA544AA5AE3DE25FE0B0B74 /* CopyAction.swift in Sources */, 7AA545D6182E36466BC8256A /* CopyConfirmationDisplayStore.swift in Sources */, + 7AA54E0EAF2761904796DB90 /* ItemListDisplayStore.swift in Sources */, + 7AA548100774B367341D450F /* ItemListDisplayAction.swift in Sources */, 7AA54BE3C277CF8AC0C4306A /* Date+.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/lockbox-ios/Action/ItemListDisplayAction.swift b/lockbox-ios/Action/ItemListDisplayAction.swift new file mode 100644 index 000000000..e0d836c58 --- /dev/null +++ b/lockbox-ios/Action/ItemListDisplayAction.swift @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Foundation + +protocol ItemListDisplayAction: Action {} + +struct ItemListFilterAction: ItemListDisplayAction { + let filteringText: String +} + +enum ItemListSortingAction: ItemListDisplayAction { + case alphabetically, recentlyUsed +} + +class ItemListDisplayActionHandler: ActionHandler { + static let shared = ItemListDisplayActionHandler() + private let dispatcher: Dispatcher + + init(dispatcher: Dispatcher = Dispatcher.shared) { + self.dispatcher = dispatcher + } + + func invoke(_ action: ItemListDisplayAction) { + self.dispatcher.dispatch(action: action) + } +} diff --git a/lockbox-ios/Common/Extensions/Date+.swift b/lockbox-ios/Common/Extensions/Date+.swift index f550da624..aaf247661 100644 --- a/lockbox-ios/Common/Extensions/Date+.swift +++ b/lockbox-ios/Common/Extensions/Date+.swift @@ -7,9 +7,10 @@ import Foundation extension Date { public init?(iso8601DateString: String) { let trimmedIsoString = iso8601DateString.replacingOccurrences( - of: "\\.\\d+", - with: "", - options: .regularExpression) + of: "\\.\\d+", + with: "", + options: .regularExpression + ) if let date = ISO8601DateFormatter().date(from: trimmedIsoString) { self = date } else { diff --git a/lockbox-ios/Common/Extensions/UIViewController+.swift b/lockbox-ios/Common/Extensions/UIViewController+.swift index 1e0c7614c..9d5c53e56 100644 --- a/lockbox-ios/Common/Extensions/UIViewController+.swift +++ b/lockbox-ios/Common/Extensions/UIViewController+.swift @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import UIKit +import RxSwift protocol ErrorView { func displayError(_ error: Error) @@ -12,6 +13,16 @@ protocol StatusAlertView { func displayTemporaryAlert(_ message: String, timeout: TimeInterval) } +struct OptionSheetButtonConfiguration { + let title: String + let tapObserver: AnyObserver? + let cancel: Bool +} + +protocol OptionSheetView { + func displayOptionSheet(buttons: [OptionSheetButtonConfiguration], title: String?) +} + extension UIViewController: ErrorView { func displayError(_ error: Error) { let alertController = UIAlertController(title: error.localizedDescription, message: nil, preferredStyle: .alert) @@ -61,6 +72,26 @@ extension UIViewController: StatusAlertView { } } +extension UIViewController: OptionSheetView { + func displayOptionSheet(buttons: [OptionSheetButtonConfiguration], title: String?) { + let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + + for buttonConfig in buttons { + let style = buttonConfig.cancel ? UIAlertActionStyle.cancel : UIAlertActionStyle.default + let action = UIAlertAction(title: buttonConfig.title, style: style) { _ in + buttonConfig.tapObserver?.onNext(()) + buttonConfig.tapObserver?.onCompleted() + } + + alertController.addAction(action) + } + + DispatchQueue.main.async { + self.present(alertController, animated: true) + } + } +} + extension UIViewController { func preloadView() { _ = self.view diff --git a/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/Contents.json b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/Contents.json new file mode 100644 index 000000000..69e68246a --- /dev/null +++ b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "iconCaretDown.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "iconCaretDown@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "iconCaretDown@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown.png b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown.png new file mode 100644 index 000000000..912e069db Binary files /dev/null and b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown.png differ diff --git a/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@2x.png b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@2x.png new file mode 100644 index 000000000..5819c3d2c Binary files /dev/null and b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@2x.png differ diff --git a/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@3x.png b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@3x.png new file mode 100644 index 000000000..2ee0610f8 Binary files /dev/null and b/lockbox-ios/Common/Resources/Assets.xcassets/down-caret.imageset/iconCaretDown@3x.png differ diff --git a/lockbox-ios/Common/Resources/Constants.swift b/lockbox-ios/Common/Resources/Constants.swift index 748921311..901efdd5c 100644 --- a/lockbox-ios/Common/Resources/Constants.swift +++ b/lockbox-ios/Common/Resources/Constants.swift @@ -28,16 +28,21 @@ struct Constant { } struct string { + static let alphabetically = NSLocalizedString("alphabetically", value: "Alphabetically", comment: "Label for the option sheet action allowing users to sort an entry list alphabetically") + static let aToZ = NSLocalizedString("a_to_z", value: "A-Z", comment: "Label for the button allowing users to sort an entry list alphabetically") static let back = NSLocalizedString("back", value: "Back", comment: "Back button title") static let cancel = NSLocalizedString("cancel", value: "Cancel", comment: "Cancel button title") static let done = NSLocalizedString("done", value: "Done", comment: "Text on button to close settings") static let fieldNameCopied = NSLocalizedString("fieldNameCopied", value: "%@ copied to clipboard", comment: "Alert text when a field has been copied, with an interpolated field name value") static let notes = NSLocalizedString("notes", value: "Notes", comment: "Section title for the notes field on the item detail screen") - static let usernamePlaceholder = NSLocalizedString("username_placeholder", value: "(no username)", comment: "Placeholder text when there is no username") static let ok = NSLocalizedString("ok", value: "OK", comment: "Ok button title") static let password = NSLocalizedString("password", value: "Password", comment: "Section title text for the password on the item detail screen") + static let recent = NSLocalizedString("recent", value: "Recent", comment: "Button title when entries list is sorted by most recently used entry") + static let recentlyUsed = NSLocalizedString("recently_used", value: "Recently Used", comment: "Label for the option sheet action allowing users to sort an entry list by the most recently used entries") + static let sortEntries = NSLocalizedString("sort_entries", value: "Sort Entries", comment: "Title for the option sheet allowing users to sort entries") static let unnamedEntry = NSLocalizedString("unnamed_entry", value: "unnamed entry", comment: "Placeholder text for when there is no entry name") static let username = NSLocalizedString("username", value: "Username", comment: "Section title text for username on the item detail screen") + static let usernamePlaceholder = NSLocalizedString("username_placeholder", value: "(no username)", comment: "Placeholder text when there is no username") static let webAddress = NSLocalizedString("web_address", value: "Web Address", comment: "Section title text for the web address on the item detail screen") static let yourLockbox = NSLocalizedString("your_lockbox", value: "Your Lockbox", comment: "Title appearing above the list of entries on the main screen of the app") static let settingsHelpSectionHeader = NSLocalizedString("settings.help.header", value: "HELP", comment: "Help section label in settings") diff --git a/lockbox-ios/Model/Item.swift b/lockbox-ios/Model/Item.swift index 301bc15ce..1d8a50f22 100644 --- a/lockbox-ios/Model/Item.swift +++ b/lockbox-ios/Model/Item.swift @@ -18,9 +18,11 @@ class Item: Codable, Equatable { var createdDate: Date? { return Date(iso8601DateString: self.created ?? "") } + var modifiedDate: Date? { return Date(iso8601DateString: self.modified ?? "") } + var lastUsedDate: Date? { return Date(iso8601DateString: self.lastUsed ?? "") } @@ -48,8 +50,8 @@ class Item: Codable, Equatable { return lhs.id == rhs.id && lhs.entry == rhs.entry && lhs.origins.elementsEqual(rhs.origins) && - lhs.modified == rhs.modified - + lhs.modified == rhs.modified && + lhs.lastUsed == rhs.lastUsed } class Builder { diff --git a/lockbox-ios/Presenter/ItemListPresenter.swift b/lockbox-ios/Presenter/ItemListPresenter.swift index dff0645e1..a3e53615c 100644 --- a/lockbox-ios/Presenter/ItemListPresenter.swift +++ b/lockbox-ios/Presenter/ItemListPresenter.swift @@ -7,29 +7,34 @@ import RxSwift import RxCocoa import RxDataSources -protocol ItemListViewProtocol: class { +protocol ItemListViewProtocol: class, OptionSheetView { func bind(items: Driver<[ItemSectionModel]>) + func bind(sortingButtonTitle: Driver) func displayEmptyStateMessaging() func hideEmptyStateMessaging() } -struct ItemListText { +struct ItemListTextSort { let items: [Item] let text: String + let sortingOption: ItemListSortingAction } -extension ItemListText: Equatable { - static func ==(lhs: ItemListText, rhs: ItemListText) -> Bool { - return lhs.items == rhs.items && lhs.text == rhs.text +extension ItemListTextSort: Equatable { + static func ==(lhs: ItemListTextSort, rhs: ItemListTextSort) -> Bool { + return lhs.items == rhs.items && + lhs.text == rhs.text && + lhs.sortingOption == rhs.sortingOption } } class ItemListPresenter { private weak var view: ItemListViewProtocol? private var routeActionHandler: RouteActionHandler + private var itemListDisplayActionHandler: ItemListDisplayActionHandler private var dataStore: DataStore + private var itemListDisplayStore: ItemListDisplayStore private var disposeBag = DisposeBag() - private let filterTextSubject = BehaviorSubject(value: "") lazy private(set) var itemSelectedObserver: AnyObserver = { return Binder(self) { target, itemId in @@ -48,54 +53,127 @@ class ItemListPresenter { }() lazy private(set) var filterTextObserver: AnyObserver = { - return self.filterTextSubject.asObserver() + return Binder(self) { target, filterText in + target.itemListDisplayActionHandler.invoke(ItemListFilterAction(filteringText: filterText)) + }.asObserver() + }() + + lazy private(set) var sortingButtonObserver: AnyObserver = { + return Binder(self) { target, _ in + target.view?.displayOptionSheet(buttons: [ + OptionSheetButtonConfiguration( + title: Constant.string.alphabetically, + tapObserver: target.alphabeticSortObserver, + cancel: false), + OptionSheetButtonConfiguration( + title: Constant.string.recentlyUsed, + tapObserver: target.recentlyUsedSortObserver, + cancel: false), + OptionSheetButtonConfiguration( + title: Constant.string.cancel, + tapObserver: nil, + cancel: true) + ], title: Constant.string.sortEntries) + }.asObserver() + }() + + lazy private var alphabeticSortObserver: AnyObserver = { + return Binder(self) { target, _ in + target.itemListDisplayActionHandler.invoke(ItemListSortingAction.alphabetically) + }.asObserver() + }() + + lazy private var recentlyUsedSortObserver: AnyObserver = { + return Binder(self) { target, _ in + target.itemListDisplayActionHandler.invoke(ItemListSortingAction.recentlyUsed) + }.asObserver() }() init(view: ItemListViewProtocol, routeActionHandler: RouteActionHandler = RouteActionHandler.shared, - dataStore: DataStore = DataStore.shared) { + itemListDisplayActionHandler: ItemListDisplayActionHandler = ItemListDisplayActionHandler.shared, + dataStore: DataStore = DataStore.shared, + itemListDisplayStore: ItemListDisplayStore = ItemListDisplayStore.shared) { self.view = view self.routeActionHandler = routeActionHandler + self.itemListDisplayActionHandler = itemListDisplayActionHandler self.dataStore = dataStore + self.itemListDisplayStore = itemListDisplayStore } func onViewReady() { - let listDriver = Observable.combineLatest(self.dataStore.onItemList, self.filterTextSubject.asObservable()) - .map { (latest: ([Item], String)) -> ItemListText in - return ItemListText(items: latest.0, text: latest.1) - } - .distinctUntilChanged() - .do(onNext: { latest in - if latest.items.isEmpty { + let itemListObservable = self.dataStore.onItemList + .do(onNext: { items in + if items.isEmpty { self.view?.displayEmptyStateMessaging() } else { self.view?.hideEmptyStateMessaging() } }) - .filter { latest in - return !latest.items.isEmpty + .filter { items in + return !items.isEmpty } - .map { (latest: ItemListText) -> [Item] in - if latest.text.isEmpty { - return latest.items - } - return latest.items.filter { item -> Bool in - return [item.entry.username, item.origins.first, item.title] - .flatMap { $0?.localizedCaseInsensitiveContains(latest.text) ?? false } - .reduce(false) { $0 || $1 } + let itemSortObservable = self.itemListDisplayStore.listDisplay + .filterByType(class: ItemListSortingAction.self) + + let filterTextObservable = self.itemListDisplayStore.listDisplay + .filterByType(class: ItemListFilterAction.self) + + let listDriver = self.createItemListDriver( + itemListObservable: itemListObservable, + filterTextObservable: filterTextObservable, + itemSortObservable: itemSortObservable + ) + + self.view?.bind(items: listDriver) + + let itemSortTextDriver = itemSortObservable + .asDriver(onErrorJustReturn: .alphabetically) + .map { itemSortAction -> String in + switch itemSortAction { + case .alphabetically: + return Constant.string.aToZ + case .recentlyUsed: + return Constant.string.recent } - } - .map { items -> [ItemSectionModel] in - return [ItemSectionModel(model: 0, items: self.configurationsFromItems(items))] } - .asDriver(onErrorJustReturn: []) - self.view?.bind(items: listDriver) + self.view?.bind(sortingButtonTitle: itemSortTextDriver) + + self.itemListDisplayActionHandler.invoke(ItemListSortingAction.alphabetically) + self.itemListDisplayActionHandler.invoke(ItemListFilterAction(filteringText: "")) } } extension ItemListPresenter { + fileprivate func createItemListDriver(itemListObservable: Observable<[Item]>, + filterTextObservable: Observable, + itemSortObservable: Observable) -> Driver<[ItemSectionModel]> { // swiftlint:disable:this line_length + return Observable.combineLatest(itemListObservable, filterTextObservable, itemSortObservable) + .map { (latest: ([Item], ItemListFilterAction, ItemListSortingAction)) -> ItemListTextSort in + return ItemListTextSort(items: latest.0, text: latest.1.filteringText, sortingOption: latest.2) + } + .distinctUntilChanged() + .map { (latest: ItemListTextSort) -> [Item] in + let baseDate = Date(timeIntervalSince1970: 0) + + return self.filterItemsForText(latest.text, items: latest.items) + .sorted { lhs, rhs -> Bool in + switch latest.sortingOption { + case .alphabetically: + return lhs.title ?? "" < rhs.title ?? "" + case .recentlyUsed: + return lhs.lastUsedDate ?? baseDate > rhs.lastUsedDate ?? baseDate + } + } + } + .map { items -> [ItemSectionModel] in + return [ItemSectionModel(model: 0, items: self.configurationsFromItems(items))] + } + .asDriver(onErrorJustReturn: []) + } + // The typecasting and force-cast in this function are due to a bug in the Swift compiler that will be fixed in // the Swift 4.1 release. fileprivate func configurationsFromItems(_ items: [Item]) -> [T] { @@ -112,6 +190,22 @@ extension ItemListPresenter { return searchCell + itemCells as! [T] // swiftlint:disable:this force_cast } + fileprivate func filterItemsForText(_ text: String, items: [Item]) -> [Item] { + if text.isEmpty { + return items + } + + return items.filter { item -> Bool in + return [item.entry.username, item.origins.first, item.title] + .flatMap { + $0?.localizedCaseInsensitiveContains(text) ?? false + } + .reduce(false) { + $0 || $1 + } + } + } + func settingsTapped() { routeActionHandler.invoke(MainRouteAction.settings) } diff --git a/lockbox-ios/Store/DataStore.swift b/lockbox-ios/Store/DataStore.swift index 6182dc5be..9d2c2614a 100644 --- a/lockbox-ios/Store/DataStore.swift +++ b/lockbox-ios/Store/DataStore.swift @@ -11,10 +11,10 @@ class DataStore { public static let shared = DataStore() fileprivate let disposeBag = DisposeBag() - fileprivate var itemList = ReplaySubject<[String: Item]>.create(bufferSize: 1) - fileprivate var initialized = ReplaySubject.create(bufferSize: 1) - fileprivate var opened = ReplaySubject.create(bufferSize: 1) - fileprivate var locked = ReplaySubject.create(bufferSize: 1) + fileprivate var itemList = BehaviorRelay<[String: Item]>(value: [:]) + fileprivate var initialized = BehaviorRelay(value: false) + fileprivate var opened = BehaviorRelay(value: false) + fileprivate var locked = BehaviorRelay(value: true) public var onItemList: Observable<[Item]> { return self.itemList.asObservable() @@ -22,7 +22,7 @@ class DataStore { return Array(itemDictionary.values) } .distinctUntilChanged { lhList, rhList in - return lhList.elementsEqual(rhList) + return lhList == rhList } } @@ -44,7 +44,7 @@ class DataStore { .subscribe(onNext: { action in switch action { case .list(let list): - self.itemList.onNext(list) + self.itemList.accept(list) case .updated(let item): self.itemList.take(1) .map { items in @@ -59,11 +59,11 @@ class DataStore { .bind(to: self.itemList) .disposed(by: self.disposeBag) case .locked(let locked): - self.locked.onNext(locked) + self.locked.accept(locked) case .initialized(let initialized): - self.initialized.onNext(initialized) + self.initialized.accept(initialized) case .opened(let opened): - self.opened.onNext(opened) + self.opened.accept(opened) } }) .disposed(by: self.disposeBag) diff --git a/lockbox-ios/Store/ItemListDisplayStore.swift b/lockbox-ios/Store/ItemListDisplayStore.swift new file mode 100644 index 000000000..65d8de83f --- /dev/null +++ b/lockbox-ios/Store/ItemListDisplayStore.swift @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Foundation +import RxSwift +import RxCocoa + +class ItemListDisplayStore { + static let shared = ItemListDisplayStore() + private let disposeBag = DisposeBag() + + private let dispatcher: Dispatcher + private let _itemListDisplay = PublishSubject() + + public var listDisplay: Observable { + return _itemListDisplay.asObservable() + } + + init(dispatcher: Dispatcher = Dispatcher.shared) { + self.dispatcher = dispatcher + + self.dispatcher.register + .filterByType(class: ItemListDisplayAction.self) + .bind(to: self._itemListDisplay) + .disposed(by: self.disposeBag) + } +} diff --git a/lockbox-ios/View/ItemListView.swift b/lockbox-ios/View/ItemListView.swift index 6c54aa1c0..e86cd5c30 100644 --- a/lockbox-ios/View/ItemListView.swift +++ b/lockbox-ios/View/ItemListView.swift @@ -71,15 +71,37 @@ extension ItemListView: ItemListViewProtocol { items.drive(self.tableView.rx.items(dataSource: dataSource)).disposed(by: self.disposeBag) } + func bind(sortingButtonTitle: Driver) { + guard let button = self.navigationItem.leftBarButtonItem?.customView as? UIButton else { + fatalError("no sorting button!") + } + + sortingButtonTitle + .drive(button.rx.title()) + .disposed(by: self.disposeBag) + } + func displayEmptyStateMessaging() { guard let emptyStateView = Bundle.main.loadNibNamed("EmptyList", owner: self)?[0] as? UIView else { return } self.tableView.backgroundView?.addSubview(emptyStateView) + + guard let button = self.navigationItem.leftBarButtonItem?.customView as? UIButton else { + fatalError("no sorting button!") + } + + button.isHidden = true } func hideEmptyStateMessaging() { self.tableView.backgroundView?.subviews.forEach({ $0.removeFromSuperview() }) + + guard let button = self.navigationItem.leftBarButtonItem?.customView as? UIButton else { + fatalError("no sorting button!") + } + + button.isHidden = false } } @@ -93,7 +115,7 @@ extension ItemListView { switch cellConfiguration { case .Search: guard let cell = tableView.dequeueReusableCell(withIdentifier: "filtercell") as? FilterCell, - let presenter = self.presenter else { + let presenter = self.presenter else { fatalError("couldn't find the right cell or presenter!") } @@ -157,6 +179,33 @@ extension ItemListView { prefButton.tintColor = .white self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: prefButton) + let sortingButton = UIButton(type: .custom) + sortingButton.adjustsImageWhenHighlighted = false + + let sortingImage = UIImage(named: "down-caret")?.withRenderingMode(.alwaysTemplate) + sortingButton.setImage(sortingImage, for: .normal) + sortingButton.setTitle(Constant.string.aToZ, for: .normal) + + sortingButton.contentHorizontalAlignment = .left + sortingButton.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + sortingButton.titleEdgeInsets = UIEdgeInsets(top: 0, left: 5, bottom: 0, right: -20) + sortingButton.setTitleColor(.white, for: .normal) + sortingButton.setTitleColor(.lightGray, for: .highlighted) + sortingButton.setTitleColor(.lightGray, for: .selected) + sortingButton.tintColor = .white + + sortingButton.addConstraint(NSLayoutConstraint( + item: sortingButton, + attribute: .width, + relatedBy: .equal, + toItem: nil, + attribute: .notAnAttribute, + multiplier: 1.0, + constant: 100) + ) + + self.navigationItem.leftBarButtonItem = UIBarButtonItem(customView: sortingButton) + self.navigationItem.title = Constant.string.yourLockbox self.navigationController?.navigationBar.titleTextAttributes = [ @@ -164,10 +213,17 @@ extension ItemListView { NSAttributedStringKey.font: UIFont.systemFont(ofSize: 18, weight: .semibold) ] - guard let presenter = presenter else { return } + guard let presenter = presenter else { + return + } + prefButton.rx.tap - .bind(to: presenter.onSettingsTapped) - .disposed(by: self.disposeBag) + .bind(to: presenter.onSettingsTapped) + .disposed(by: self.disposeBag) + + sortingButton.rx.tap + .bind(to: presenter.sortingButtonObserver) + .disposed(by: self.disposeBag) } fileprivate func styleTableViewBackground() { diff --git a/lockbox-iosTests/DataStoreSpec.swift b/lockbox-iosTests/DataStoreSpec.swift index 5a2b4544c..711b01a54 100644 --- a/lockbox-iosTests/DataStoreSpec.swift +++ b/lockbox-iosTests/DataStoreSpec.swift @@ -65,7 +65,7 @@ class DataStoreSpec: QuickSpec { it("doesn't push the same list twice in a row") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.list(list: itemList)) - expect(itemListObserver.events.count).to(equal(1)) + expect(itemListObserver.events.count).to(equal(2)) } it("pushes subsequent different lists") { @@ -73,12 +73,12 @@ class DataStoreSpec: QuickSpec { newItemList["wwkjlkjm"] = Item.Builder().id("wwkjlkjm").build() self.dispatcher.fakeRegistration.onNext(DataStoreAction.list(list: newItemList)) - expect(itemListObserver.events.count).to(equal(2)) + expect(itemListObserver.events.count).to(equal(3)) } it("does do anything with non-datastore actions") { self.dispatcher.fakeRegistration.onNext(LoginRouteAction.fxa) - expect(itemListObserver.events.count).to(equal(1)) + expect(itemListObserver.events.count).to(equal(2)) } } @@ -152,18 +152,18 @@ class DataStoreSpec: QuickSpec { it("doesn't push the same value twice in a row") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.initialized(initialized: true)) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } it("pushes subsequent different values") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.initialized(initialized: false)) - expect(boolObserver.events.count).to(equal(2)) + expect(boolObserver.events.count).to(equal(3)) } it("does not do anything with non-datastore actions") { self.dispatcher.fakeRegistration.onNext(LoginRouteAction.fxa) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } } @@ -187,18 +187,18 @@ class DataStoreSpec: QuickSpec { it("doesn't push the same value twice in a row") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.opened(opened: true)) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } it("pushes subsequent different values") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.opened(opened: false)) - expect(boolObserver.events.count).to(equal(2)) + expect(boolObserver.events.count).to(equal(3)) } it("does not do anything with non-datastore actions") { self.dispatcher.fakeRegistration.onNext(LoginRouteAction.fxa) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } } @@ -222,18 +222,18 @@ class DataStoreSpec: QuickSpec { it("doesn't push the same value twice in a row") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.locked(locked: false)) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } it("pushes subsequent different values") { self.dispatcher.fakeRegistration.onNext(DataStoreAction.locked(locked: true)) - expect(boolObserver.events.count).to(equal(2)) + expect(boolObserver.events.count).to(equal(3)) } it("does not do anything with non-datastore actions") { self.dispatcher.fakeRegistration.onNext(LoginRouteAction.fxa) - expect(boolObserver.events.count).to(equal(1)) + expect(boolObserver.events.count).to(equal(2)) } } } diff --git a/lockbox-iosTests/ItemListDisplayActionSpec.swift b/lockbox-iosTests/ItemListDisplayActionSpec.swift new file mode 100644 index 000000000..c73164845 --- /dev/null +++ b/lockbox-iosTests/ItemListDisplayActionSpec.swift @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Foundation +import Quick +import Nimble +import RxSwift + +@testable import Lockbox + +class ItemListDisplayActionSpec: QuickSpec { + class FakeDispatcher: Dispatcher { + var actionTypeArgument: Action? + + override func dispatch(action: Action) { + self.actionTypeArgument = action + } + } + + private var dispatcher: FakeDispatcher! + var subject: ItemListDisplayActionHandler! + + override func spec() { + describe("RouteActionHandler") { + beforeEach { + self.dispatcher = FakeDispatcher() + self.subject = ItemListDisplayActionHandler(dispatcher: self.dispatcher) + } + + describe("invoke") { + beforeEach { + self.subject.invoke(ItemListSortingAction.recentlyUsed) + } + + it("dispatches actions to the dispatcher") { + expect(self.dispatcher.actionTypeArgument).notTo(beNil()) + let argument = self.dispatcher.actionTypeArgument as! ItemListSortingAction + expect(argument).to(equal(ItemListSortingAction.recentlyUsed)) + } + } + } + } +} diff --git a/lockbox-iosTests/ItemListDisplayStoreSpec.swift b/lockbox-iosTests/ItemListDisplayStoreSpec.swift new file mode 100644 index 000000000..425967d08 --- /dev/null +++ b/lockbox-iosTests/ItemListDisplayStoreSpec.swift @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Foundation +import Quick +import Nimble +import RxSwift +import RxTest + +@testable import Lockbox + +class ItemListDisplayStoreSpec: QuickSpec { + class FakeDispatcher: Dispatcher { + let fakeRegistration = PublishSubject() + + override var register: Observable { + return self.fakeRegistration.asObservable() + } + } + + private var scheduler: TestScheduler = TestScheduler(initialClock: 1) + private var disposeBag = DisposeBag() + + private var dispatcher: FakeDispatcher! + var subject: ItemListDisplayStore! + + override func spec() { + describe("ItemListDisplayStore") { + beforeEach { + self.dispatcher = FakeDispatcher() + self.subject = ItemListDisplayStore(dispatcher: self.dispatcher) + } + + describe("onRoute") { + var displayObserver = self.scheduler.createObserver(ItemListDisplayAction.self) + + beforeEach { + displayObserver = self.scheduler.createObserver(ItemListDisplayAction.self) + + self.subject.listDisplay + .subscribe(displayObserver) + .disposed(by: self.disposeBag) + + self.dispatcher.fakeRegistration.onNext(ItemListSortingAction.alphabetically) + } + + it("pushes dispatched route actions to observers") { + expect(displayObserver.events.last).notTo(beNil()) + let element = displayObserver.events.last!.value.element as! ItemListSortingAction + expect(element).to(equal(ItemListSortingAction.alphabetically)) + } + + it("pushes new actions to observers") { + self.dispatcher.fakeRegistration.onNext(ItemListFilterAction(filteringText: "blah")) + expect(displayObserver.events.count).to(equal(2)) + } + + it("does not push non-ItemListDisplay events") { + self.dispatcher.fakeRegistration.onNext(DataStoreAction.locked(locked: false)) + expect(displayObserver.events.count).to(equal(1)) + } + } + } + } +} diff --git a/lockbox-iosTests/ItemListPresenterSpec.swift b/lockbox-iosTests/ItemListPresenterSpec.swift index 7955946b2..0c74b953a 100644 --- a/lockbox-iosTests/ItemListPresenterSpec.swift +++ b/lockbox-iosTests/ItemListPresenterSpec.swift @@ -14,14 +14,22 @@ import RxTest class ItemListPresenterSpec: QuickSpec { class FakeItemListView: ItemListViewProtocol { var itemsObserver: TestableObserver<[ItemSectionModel]>! + var sortingButtonTitleObserver: TestableObserver! var displayEmptyStateMessagingCalled = false var hideEmptyStateMessagingCalled = false let disposeBag = DisposeBag() + var displayOptionSheetButtons: [OptionSheetButtonConfiguration]? + var displayOptionSheetTitle: String? + func bind(items: Driver<[ItemSectionModel]>) { items.drive(itemsObserver).disposed(by: self.disposeBag) } + func bind(sortingButtonTitle: Driver) { + sortingButtonTitle.drive(sortingButtonTitleObserver).disposed(by: self.disposeBag) + } + func displayEmptyStateMessaging() { self.displayEmptyStateMessagingCalled = true } @@ -29,6 +37,11 @@ class ItemListPresenterSpec: QuickSpec { func hideEmptyStateMessaging() { self.hideEmptyStateMessagingCalled = true } + + func displayOptionSheet(buttons: [OptionSheetButtonConfiguration], title: String?) { + self.displayOptionSheetButtons = buttons + self.displayOptionSheetTitle = title + } } class FakeRouteActionHandler: RouteActionHandler { @@ -39,6 +52,14 @@ class ItemListPresenterSpec: QuickSpec { } } + class FakeItemListDisplayActionHandler: ItemListDisplayActionHandler { + var invokeActionArgument: [ItemListDisplayAction] = [] + + override func invoke(_ action: ItemListDisplayAction) { + self.invokeActionArgument.append(action) + } + } + class FakeDataStore: DataStore { var itemListObservable: TestableObservable<[Item]>? @@ -47,9 +68,19 @@ class ItemListPresenterSpec: QuickSpec { } } + class FakeItemListDisplayStore: ItemListDisplayStore { + var itemListDisplaySubject = PublishSubject() + + override var listDisplay: Observable { + return self.itemListDisplaySubject.asObservable() + } + } + private var view: FakeItemListView! private var routeActionHandler: FakeRouteActionHandler! + private var itemListDisplayActionHandler: FakeItemListDisplayActionHandler! private var dataStore: FakeDataStore! + private var itemListDisplayStore: FakeItemListDisplayStore! private let scheduler = TestScheduler(initialClock: 0) private let disposeBag = DisposeBag() var subject: ItemListPresenter! @@ -58,15 +89,19 @@ class ItemListPresenterSpec: QuickSpec { describe("ItemListPresenter") { beforeEach { self.view = FakeItemListView() - self.dataStore = FakeDataStore() self.routeActionHandler = FakeRouteActionHandler() - + self.itemListDisplayActionHandler = FakeItemListDisplayActionHandler() + self.dataStore = FakeDataStore() + self.itemListDisplayStore = FakeItemListDisplayStore() self.view.itemsObserver = self.scheduler.createObserver([ItemSectionModel].self) + self.view.sortingButtonTitleObserver = self.scheduler.createObserver(String.self) self.subject = ItemListPresenter( view: self.view, routeActionHandler: self.routeActionHandler, - dataStore: self.dataStore + itemListDisplayActionHandler: self.itemListDisplayActionHandler, + dataStore: self.dataStore, + itemListDisplayStore: self.itemListDisplayStore ) } @@ -85,6 +120,7 @@ class ItemListPresenterSpec: QuickSpec { describe("when the datastore pushes a populated list of items") { let title1 = "meow" + let title2 = "aaaaaa" let username = "cats@cats.com" let id1 = "fdsdfsfdsfds" @@ -96,44 +132,47 @@ class ItemListPresenterSpec: QuickSpec { .username(username) .build() ) + .lastUsed("1970-01-01T00:03:20.4500Z") .id(id1) .build(), - Item.Builder().origins(["www.dogs.com"]).id(id2).build() + Item.Builder() + .origins(["www.dogs.com"]) + .lastUsed("1970-01-01T00:02:20.4500Z") + .id(id2) + .build(), + Item.Builder() + .title(title2) + .build() ] beforeEach { self.dataStore.itemListObservable = self.scheduler.createHotObservable([next(100, items)]) self.subject.onViewReady() + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListFilterAction(filteringText: "")) + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListSortingAction.alphabetically) self.scheduler.start() } - it("tells the view to display the items") { + it("tells the view to hide the empty state messaging") { + expect(self.view.hideEmptyStateMessagingCalled).to(beTrue()) + } + + it("tells the view to display the items in alphabetic order by title") { let expectedItemConfigurations = [ ItemListCellConfiguration.Search, - ItemListCellConfiguration.Item(title: title1, username: username, id: id1), - ItemListCellConfiguration.Item(title: "", username: Constant.string.usernamePlaceholder, id: id2) + ItemListCellConfiguration.Item(title: "", username: Constant.string.usernamePlaceholder, id: id2), + ItemListCellConfiguration.Item(title: title2, username: Constant.string.usernamePlaceholder, id: nil), + ItemListCellConfiguration.Item(title: title1, username: username, id: id1) ] expect(self.view.itemsObserver.events.first!.value.element).notTo(beNil()) let configuration = self.view.itemsObserver.events.first!.value.element! expect(configuration.first!.items).to(equal(expectedItemConfigurations)) } - it("tells the view to hide the empty state messaging") { - expect(self.view.hideEmptyStateMessagingCalled).to(beTrue()) - } - describe("when text is entered into the search bar") { - let textSubject = PublishSubject() - - beforeEach { - textSubject - .bind(to: self.subject.filterTextObserver) - .disposed(by: self.disposeBag) - } - describe("when the text matches an item's username") { beforeEach { - textSubject.onNext("cat") + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListFilterAction(filteringText: "cat")) } it("updates the view with the appropriate items") { @@ -150,7 +189,7 @@ class ItemListPresenterSpec: QuickSpec { describe("when the text matches an item's origins") { beforeEach { - textSubject.onNext("dog") + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListFilterAction(filteringText: "dog")) } it("updates the view with the appropriate items") { @@ -167,7 +206,7 @@ class ItemListPresenterSpec: QuickSpec { describe("when the text matches an item's title") { beforeEach { - textSubject.onNext("me") + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListFilterAction(filteringText: "me")) } it("updates the view with the appropriate items") { @@ -182,6 +221,41 @@ class ItemListPresenterSpec: QuickSpec { } } } + + describe("when sorting method switches to recently used") { + beforeEach { + self.itemListDisplayStore.itemListDisplaySubject.onNext(ItemListSortingAction.recentlyUsed) + } + + it("pushes the new configuration with the items") { + let expectedItemConfigurations = [ + ItemListCellConfiguration.Search, + ItemListCellConfiguration.Item(title: title1, username: username, id: id1), + ItemListCellConfiguration.Item(title: "", username: Constant.string.usernamePlaceholder, id: id2), + ItemListCellConfiguration.Item(title: title2, username: Constant.string.usernamePlaceholder, id: nil) + ] + expect(self.view.itemsObserver.events.last!.value.element).notTo(beNil()) + let configuration = self.view.itemsObserver.events.last!.value.element! + expect(configuration.last!.items).to(equal(expectedItemConfigurations)) + } + } + } + } + + describe("filterText") { + let text = "entered text" + + beforeEach { + let textObservable = self.scheduler.createColdObservable([next(40, text)]) + + textObservable.bind(to: self.subject.filterTextObserver).disposed(by: self.disposeBag) + + self.scheduler.start() + } + + it("dispatches the filtertext item list display action") { + let action = self.itemListDisplayActionHandler.invokeActionArgument.popLast() as! ItemListFilterAction + expect(action.filteringText).to(equal(text)) } } @@ -224,6 +298,47 @@ class ItemListPresenterSpec: QuickSpec { } } } + + describe("sortingButton") { + beforeEach { + let voidObservable = self.scheduler.createColdObservable([next(50, ())]) + + voidObservable + .bind(to: self.subject.sortingButtonObserver) + .disposed(by: self.disposeBag) + + self.scheduler.start() + } + + it("tells the view to display an option sheet") { + expect(self.view.displayOptionSheetButtons).notTo(beNil()) + expect(self.view.displayOptionSheetTitle).notTo(beNil()) + + expect(self.view.displayOptionSheetTitle).to(equal(Constant.string.sortEntries)) + } + + describe("tapping alphabetically") { + beforeEach { + self.view.displayOptionSheetButtons![0].tapObserver!.onNext(()) + } + + it("dispatches the alphabetically ItemListSortingAction") { + let action = self.itemListDisplayActionHandler.invokeActionArgument.popLast() as! ItemListSortingAction + expect(action).to(equal(ItemListSortingAction.alphabetically)) + } + } + + describe("tapping recently used") { + beforeEach { + self.view.displayOptionSheetButtons![1].tapObserver!.onNext(()) + } + + it("dispatches the alphabetically ItemListSortingAction") { + let action = self.itemListDisplayActionHandler.invokeActionArgument.popLast() as! ItemListSortingAction + expect(action).to(equal(ItemListSortingAction.recentlyUsed)) + } + } + } } } } diff --git a/lockbox-iosTests/ItemListViewSpec.swift b/lockbox-iosTests/ItemListViewSpec.swift index 5265d3a9c..16a0e95b3 100644 --- a/lockbox-iosTests/ItemListViewSpec.swift +++ b/lockbox-iosTests/ItemListViewSpec.swift @@ -23,6 +23,7 @@ class ItemListViewSpec: QuickSpec { var onViewReadyCalled = false var fakeItemSelectedObserver: TestableObserver! var fakeFilterTextObserver: TestableObserver! + var fakeSortingButtonObserver: TestableObserver! override func onViewReady() { onViewReadyCalled = true @@ -35,6 +36,10 @@ class ItemListViewSpec: QuickSpec { override var filterTextObserver: AnyObserver { return self.fakeFilterTextObserver.asObserver() } + + override var sortingButtonObserver: AnyObserver { + return self.fakeSortingButtonObserver.asObserver() + } } private var presenter: FakeItemListPresenter! @@ -50,6 +55,7 @@ class ItemListViewSpec: QuickSpec { self.presenter = FakeItemListPresenter(view: self.subject) self.presenter.fakeItemSelectedObserver = self.scheduler.createObserver(String?.self) self.presenter.fakeFilterTextObserver = self.scheduler.createObserver(String.self) + self.presenter.fakeSortingButtonObserver = self.scheduler.createObserver(Void.self) self.subject.presenter = self.presenter _ = UINavigationController(rootViewController: self.subject) @@ -64,7 +70,7 @@ class ItemListViewSpec: QuickSpec { expect(self.presenter.onViewReadyCalled).to(beTrue()) } - describe(".displayItems()") { + describe(".bind(items:)") { let item1Title = "item1" let item1Username = "bleh" let item2Title = "sum item" @@ -118,6 +124,20 @@ class ItemListViewSpec: QuickSpec { } } + describe("bind(sortingButtonTitle:)") { + let newTitle = "yum" + + beforeEach { + self.subject.bind(sortingButtonTitle: Driver.just(newTitle)) + } + + it("configures the sorting button title") { + let button = self.subject.navigationItem.leftBarButtonItem!.customView as! UIButton + + expect(button.currentTitle).to(equal(newTitle)) + } + } + describe("displayEmptyStateMessaging") { beforeEach { self.subject.displayEmptyStateMessaging() @@ -126,6 +146,10 @@ class ItemListViewSpec: QuickSpec { it("adds the empty list view to the background view") { expect(self.subject.tableView.backgroundView?.subviews.count).to(equal(1)) } + + it("hides the left bar button item") { + expect(self.subject.navigationItem.leftBarButtonItem!.customView!.isHidden).to(beTrue()) + } } describe("hideEmptyStateMessaging") { @@ -137,6 +161,10 @@ class ItemListViewSpec: QuickSpec { it("removes the empty list view from the background view") { expect(self.subject.tableView.backgroundView?.subviews.count).toEventually(equal(0)) } + + it("shows the left bar button item") { + expect(self.subject.navigationItem.leftBarButtonItem!.customView!.isHidden).to(beFalse()) + } } describe("tapping a row") { @@ -172,6 +200,18 @@ class ItemListViewSpec: QuickSpec { } } + describe("tapping the sorting button") { + beforeEach { + let button = self.subject.navigationItem.leftBarButtonItem!.customView as! UIButton + + button.sendActions(for: .touchUpInside) + } + + it("tells the presenter") { + expect(self.presenter.fakeSortingButtonObserver.events.count).to(equal(1)) + } + } + describe("ItemListCell") { let items = [ ItemListCellConfiguration.Item(title: "item1", username: "bleh", id: "fdssdfdfs") diff --git a/lockbox-iosTests/ItemSpec.swift b/lockbox-iosTests/ItemSpec.swift index 7f253a757..ee0886285 100644 --- a/lockbox-iosTests/ItemSpec.swift +++ b/lockbox-iosTests/ItemSpec.swift @@ -48,6 +48,28 @@ class ItemSpec: QuickSpec { } } + describe("usedDates") { + it("computes the date from the given string") { + let item = Item.Builder() + .created("1970-01-01T00:03:20.4500Z") + .modified("1970-01-01T00:05:20.4500Z") + .lastUsed("1970-01-01T00:05:20.4500Z") + .build() + + expect(item.createdDate).to(equal(Date.init(timeIntervalSince1970: 200))) + expect(item.modifiedDate).to(equal(Date.init(timeIntervalSince1970: 320))) + expect(item.lastUsedDate).to(equal(Date.init(timeIntervalSince1970: 320))) + } + + it("returns nil when there is a no date") { + let item = Item.Builder().build() + + expect(item.createdDate).to(beNil()) + expect(item.modifiedDate).to(beNil()) + expect(item.lastUsedDate).to(beNil()) + } + } + describe("equality") { it("returns false when the ids are different", closure: { lhs = Item.Builder() diff --git a/lockbox-iosTests/UIViewController+Spec.swift b/lockbox-iosTests/UIViewController+Spec.swift index d210ed030..fc1416453 100644 --- a/lockbox-iosTests/UIViewController+Spec.swift +++ b/lockbox-iosTests/UIViewController+Spec.swift @@ -5,12 +5,14 @@ import UIKit import Quick import Nimble +import RxSwift +import RxTest @testable import Lockbox class ViewControllerSpec: QuickSpec { - var subject: (UIViewController & ErrorView)! + var subject: (UIViewController & ErrorView & StatusAlertView & OptionSheetView)! override func spec() { beforeEach { @@ -54,5 +56,33 @@ class ViewControllerSpec: QuickSpec { expect(self.subject.view.subviews.first).toEventually(beNil(), timeout: 6) } } + + describe(".displayOptionSheet") { + let title = "title!" + let buttons = [ + OptionSheetButtonConfiguration(title: "something", tapObserver: nil, cancel: false), + OptionSheetButtonConfiguration(title: "blah", tapObserver: nil, cancel: true) + ] + + beforeEach { + self.subject.displayOptionSheet(buttons: buttons, title: title) + } + + it("displays an optionsheet alert controller") { + expect(self.subject.presentedViewController).toEventually(beAnInstanceOf(UIAlertController.self)) + + let alertController = self.subject.presentedViewController as! UIAlertController + + expect(alertController.preferredStyle).to(equal(UIAlertControllerStyle.actionSheet)) + + expect(alertController.actions.first!.title).to(equal(buttons[0].title)) + expect(alertController.actions.first!.style).to(equal(UIAlertActionStyle.default)) + + expect(alertController.actions[1].title).to(equal(buttons[1].title)) + expect(alertController.actions[1].style).to(equal(UIAlertActionStyle.cancel)) + } + + // testing note: UIAlertAction handlers _not_ tested here because it's heinous to do so in Swift. + } } }