diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000000..27c5c4e745e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,326 @@ +# Architecture + + +WooCommerce iOS's architecture is the result of a **massive team effort** which involves lots of brainstorming sessions, extremely fun +coding rounds, and most of all: the sum of past experiences on the platform. + +The goal of the current document is to discuss several principles that strongly influenced our current architecture approach, along with providing +details on how each one of the layers work internally. + + + + +## **Design Principles** + +Throughout the entire architecture design process, we've priorized several key concepts which guided us all the way: + + +1. **Do NOT Reinvent the Wheel** + + Our main goal is to exploit as much as possible all of the things the platform already offers through its SDK, + for obvious reasons. + + The -non extensive- list of tools we've built upon include: [CoreData, NotificationCenter, KVO] + + +2. **Separation of concerns** + + We've emphasized a clean separation of concerns at the top level, by splitting our app into four targets: + + 1. Storage.framework: + Wraps up all of the actual CoreData interactions, and exposes a framework-agnostic Public API. + + 2. Networking.framework: + In charge of providing a Swift API around the WooCommerce REST Endpoints. + + 3. Yosemite.framework: + Encapsulates our Business Logic: is in charge of interacting with the Storage and Networking layers. + + 4. WooCommerce: + Our main target, which is expected to **only** interact with the entire stack thru the Yosemite.framework. + + +3. **Immutability** + + For a wide variety of reasons, we've opted for exposing Mutable Entities **ONLY** to our Service Layer (Yosemite.framework). + The main app's ViewControllers can gain access to [Remote, Cached] Entities only through ReadOnly instances. + + (A) Thread Safe: We're shielded from known CoreData Threading nightmares + (B) A valid object will always remain valid. This is not entirely true with plain NSManagedObjects! + (C) Enforces, at the compiler level, not to break the architecture. + + +4. **Testability** + + Every class in the entire stack (Storage / Networking / Services) has been designed with testability in mind. + This enabled us to test every single key aspect, without requiring third party tools to do so. + + +5. **Keeping it Simple** + + Compact code is amazing. But readable code is even better. Anything and everything must be easy to understand + by everyone, including the committer, at a future time. + + + + +## **Storage.framework** + +CoreData interactions are contained within the Storage framework. A set of protocols has been defined, which would, in theory, allow us to +replace CoreData with any other database. Key notes: + + +1. **CoreDataManager** + + In charge of bootstrapping the entire CoreData stack: contains a NSPersistentContainer instance, and + is responsible for loading both the Data Model and the actual `.sqlite` file. + +2. **StorageManagerType** + + Defines the public API that's expected to be conformed by any actual implementation that intends to contain + and grant access to StorageType instances. + + **Conformed by CoreDataManager.** + +3. **StorageType** + + Defines a set of framework-agnostic API's for CRUD operations over collections of Objects. + Every instance of this type is expected to be associated with a particular GCD Queue (Thread). + + **Conformed by NSManagedObjectContext** + +4. **Object** + + Defines required methods / properties, to be implemented by Stored Objects. + + **Conformed by NSManagedObject.** + +5. **StorageType+Extensions** + + The extension `StorageType+Extensions` defines a set of convenience methods, aimed at easing out WC specific + tasks (such as: `loadOrder(orderID:)`). + + + + +## **Networking.framework** + +Our Networking framework offers a Swift API around the WooCommerce's RESTful endpoints. In this section we'll do a walkthru around several +key points. + + + +### Model Entities + +ReadOnly Model Entities live at the Networking Layer level. This effectively translates into: **none** of the Models at this level is expected to have +even a single mutable property. + +Each one of the concrete structures conforms to Swift's `Decodable` protocol, which is heavily used for JSON Parsing purposes. + + + +### Parsing Model Entities! + +In order to maximize separation of concerns, parsing backend responses into Model Entities is expected to be performed (only) by means of +a concrete `Mapper` implementation: + + ``` + protocol Mapper { + associatedtype Output + func map(response: Data) throws -> Output + } + ``` + +Since our Model entities conform to `Decodable`, this results in small-footprint-mappers, along with clean and compact Unit Tests. + + + +### Network Access + +The networking layer is **entirely decoupled** from third party frameworks. We rely upon component injection to actually perform network requests: + +1. **NetworkType** + + Defines a set of API's, to be implemented by any class that offers actual Network Access. + +2. **AlamofireNetwork** + + Thin wrapper around the Alamofire library. + +3. **MockupNetwork** + + As the name implies, the Mockup Network is extensively used in Unit Tests. Allows us to simulate backend + responses without requiring third party tools. No more NSURLSession swizzling! + + + +### Building Requests + +Rather than building URL instances in multiple spots, we've opted for implementing three core tools, that, once fully initialized, are capable +of performing this task for us: + +1. **DotcomRequest** + + Represents a WordPress.com request. Set the proper API Version, method, path and parameters, and this structure + will generate a URLRequest for you. + +2. **JetpackRequest** + + Analog to DotcomRequest, this structure represents a Jetpack Endpoint request. Capable of building a ready-to-use + URLRequest for a "Jetpack Tunneled" endpoint. + +3. **AuthenticatedRequest** + + Injects a set of Credentials into anything that conforms to the URLConvertible protocol. Usually wraps up + a DotcomRequest (OR) JetpackRequest. + + + +### Remote Endpoints + +Related Endpoints are expected to be accessible by means of a concrete `Remote` implementation. The `Remote` base class offers few +convenience methods for enqueuing requests and parsing responses in a standard and cohesive way `(Mappers)`. + +`Remote(s)` receive a Network concrete instance via its initializer. This allows us to Unit Test it's behavior, by means of the `MockupNetwork` +tool, which was designed to simulate Backend Responses. + + + + +## **Yosemite.framework** + +The Yosemite framework is the keystone of our architecture. Encapsulates all of the Business Logic of our app, and interacts with both, the Networking and +Storage layers. + + + +### Main Concepts + +We've borrowed several concepts from the [WordPress FluxC library](https://github.com/wordpress-mobile/WordPress-FluxC-Android), and tailored them down +for the iOS platform (and our specific requirements): + + +1. **Actions** + + Lightweight entities expected to contain anything required to perform a specific task. + Usually implemented by means of Swift enums, but can be literally any type that conforms to the Action protocol. + + *Allowed* to have a Closure Callback to indicate Success / Failure scenarios. + + **NOTE:** Success callbacks can return data, but the "preferred" mechanism is via the EntityListener or + ResultsController tools. + +2. **Stores** + + Stores offer sets of related API's that allow you to perform related tasks. Typically each Model Entity will have an + associated Store. + + References to the `Network` and `StorageManager` instances are received at build time. This allows us to inject Mockup + Storage and Network layers, for unit testing purposes. + + Differing from our Android counterpart, Yosemite.Stores are *only expected process Actions*, and do not expose + Public API's to retrieve / observe objects. The name has been kept *for historic reasons*. + +3. **Dispatcher** + + Binds together Actions and ActionProcessors (Stores), with key differences from FluxC: + + - ActionProcessors must register themselves to handle a specific ActionType. + - Each ActionType may only have one ActionProcessor associated. + - Since each ActionType may be only handled by a single ActionProcessor, a Yosemite.Action is *allowed* to have + a Callback Closure. + +4. **ResultsController** + + Associated with a Stored.Entity, allows you to query the Storage layer, but grants you access to the *ReadOnly* version + of the Observed Entities. + Internally, implemented as a thin wrapper around NSFetchedResultsController. + +5. **EntityListener** + + Allows you to observe changes performed over DataModel Entities. Whenever the observed entity is Updated / Deleted, + callbacks will be executed. + + + +### Main Flows + + 1. Performing Tasks + + SomeAction >> Dispatcher >> SomeStore + + A. [Main App] SomeAction is built and enqueued in the main dispatcher + B. [Yosemite] The dispatcher looks up for the processor that support SomeAction.Type, and relays the Action. + C. [Yosemite] SomeStore receives the action, and performs a task + D. [Yosemite] Upon completion, SomeStore *may* (or may not) run the Action's callback (if any). + + 2. Observing a Collection of Entities + + ResultsController >> Observer + + A. [Main App] An observer (typically a ViewController) initializes a ResultsController, and subscribes to its callbacks + B. [Yosemite] ResultsController listens to Storage Layer changes that match the target criteria (Entity / Predicate) + C. [Yosemite] Whenever there are changes, the observer gets notified + D. [Yosemite] ResultsController *grants ReadOnly Access* to the stored entities + + 3. Observing a Single Entity + + EntityListener >> Observer + + A. [Main App] An observer initializes an EntityListener instance with a specific ReadOnly Entity. + B. [Yosemite] EntityListener hooks up to the Storage Layer, and listens to changes matching it's criteria. + C. [Yosemite] Whenever an Update / Deletion OP is performed on the target entity, the Observer is notified. + + + +### Model Entities + +It's important to note that in the proposed architecture Model Entities must be defined in two spots: + +A. **Storage.framework** + + New entities are defined in the CoreData Model, and its code is generated thru the Model Editor. + +B. **Networking.framework** + + Entities are typically implemented as `structs` with readonly properties, and Decodable conformance. + +In order to avoid code duplication we've taken a few shortcuts: + +* All of the 'Networking Entities' are typealiased as 'Yosemite Entities', and exposed publicly (Model.swift). + This allows us to avoid the need for importing `Networking` in the main app, and also lets us avoid reimplementing, yet again, + the same entities that have been defined twice. + +* Since ResultsController uses internally a FRC, the Storage.Model *TYPE* is required for its initialization. + We may revisit and fix this shortcoming in upcoming iterations. + + As a workaround to prevent the need for `import Storage` statements, all of the Storage.Entities that are used in + ResultsController instances through the main app have been re-exported by means of a typealias. + + + +### Mapping: Storage.Entity <> Yosemite.Entity + +It's important to note that the Main App is only expected to interact with ReadOnly Entities (Yosemite). We rely on two main protocols to convert a Mutable Entity +into a ReadOnly instance: + + +* **ReadOnlyConvertible** + + Protocol implemented by all of the Storage.Entities, allows us to obtain a ReadOnly Type matching the Receiver's Payload. + Additionally, this protocol defines an API to update the receiver's fields, given a ReadOnly instance (potentially a Backend + response we've received from the Networking layer) + +* **ReadOnlyType** + + Protocol implemented by *STRONG* Storage.Entities. Allows us to determine if a ReadOnly type represents a given Mutable instance. + Few notes that led us to this approach: + + A. Why is it only supported by *Strong* stored types?: because in order to determine if A represents B, a + primaryKey is needed. Weak types might not have a pK accessible. + + B. We've intentionally avoided adding a objectID field to the Yosemite.Entities, because in order to do this in a clean + way, we would have ended up defining Model structs x3 (instead of simply re-exporting the Networking ones). + + C. "Weak Entities" are okay not to conform to this protocol. In turn, their parent (strong entities) can be observed. + diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 8a1418296d5..84787f7f303 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ B518662420A099BF00037A38 /* AlamofireNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = B518662320A099BF00037A38 /* AlamofireNetwork.swift */; }; B518662A20A09C6F00037A38 /* OrdersRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B518662920A09C6F00037A38 /* OrdersRemoteTests.swift */; }; B518663520A0A2E800037A38 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = B518663320A0A2E800037A38 /* Constants.swift */; }; + B556FD69211CE2EC00B5DAE7 /* HTTPStatusCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556FD68211CE2EC00B5DAE7 /* HTTPStatusCode.swift */; }; B557D9ED209753AA005962F4 /* Networking.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B557D9E3209753AA005962F4 /* Networking.framework */; }; B557D9F4209753AA005962F4 /* Networking.h in Headers */ = {isa = PBXBuildFile; fileRef = B557D9E6209753AA005962F4 /* Networking.h */; settings = {ATTRIBUTES = (Public, ); }; }; B557DA0220975500005962F4 /* JetpackRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B557D9FF209754FF005962F4 /* JetpackRequest.swift */; }; @@ -154,6 +155,7 @@ B518662920A09C6F00037A38 /* OrdersRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersRemoteTests.swift; sourceTree = ""; }; B518663320A0A2E800037A38 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; B518663420A0A2E800037A38 /* Loader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Loader.swift; sourceTree = ""; }; + B556FD68211CE2EC00B5DAE7 /* HTTPStatusCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPStatusCode.swift; sourceTree = ""; }; B557D9E3209753AA005962F4 /* Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; B557D9E6209753AA005962F4 /* Networking.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Networking.h; sourceTree = ""; }; B557D9E7209753AA005962F4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -260,6 +262,7 @@ B518662120A097C200037A38 /* Network.swift */, B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockupNetwork.swift */, + B556FD68211CE2EC00B5DAE7 /* HTTPStatusCode.swift */, ); path = Network; sourceTree = ""; @@ -686,6 +689,7 @@ B557DA0220975500005962F4 /* JetpackRequest.swift in Sources */, B56C1EB820EA76F500D749F9 /* Site.swift in Sources */, B505F6CD20BEE37E00BB1B69 /* AccountMapper.swift in Sources */, + B556FD69211CE2EC00B5DAE7 /* HTTPStatusCode.swift in Sources */, B557DA0D20975DB1005962F4 /* WordPressAPIVersion.swift in Sources */, 74A1D26F21189EA100931DFA /* SiteVisitStatsRemote.swift in Sources */, B557DA1D20979E7D005962F4 /* Order.swift in Sources */, diff --git a/Networking/Networking/Model/Address.swift b/Networking/Networking/Model/Address.swift index 679d1df0fc1..333a45d2573 100644 --- a/Networking/Networking/Model/Address.swift +++ b/Networking/Networking/Model/Address.swift @@ -16,6 +16,13 @@ public struct Address: Decodable { public let phone: String? public let email: String? + /// Make Address conform to Error protocol. + /// + enum AddressParseError: Error { + case missingCountry + } + + /// Designated Initializer. /// public init(firstName: String, lastName: String, company: String?, address1: String, address2: String?, city: String, state: String, postcode: String, country: String, phone: String?, email: String?) { @@ -31,6 +38,30 @@ public struct Address: Decodable { self.phone = phone self.email = email } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let firstName = try container.decode(String.self, forKey: .firstName) + let lastName = try container.decode(String.self, forKey: .lastName) + let company = try container.decodeIfPresent(String.self, forKey: .company) + let address1 = try container.decode(String.self, forKey: .address1) + let address2 = try container.decodeIfPresent(String.self, forKey: .address2) + let city = try container.decode(String.self, forKey: .city) + let state = try container.decode(String.self, forKey: .state) + let postcode = try container.decode(String.self, forKey: .postcode) + let country = try container.decode(String.self, forKey: .country) + let phone = try container.decodeIfPresent(String.self, forKey: .phone) + let email = try container.decodeIfPresent(String.self, forKey: .email) + + // Check for an empty country, because on Android that's how + // we determine if the shipping address should be considered empty. + if country.isEmpty { + throw AddressParseError.missingCountry + } + + self.init(firstName: firstName, lastName: lastName, company: company, address1: address1, address2: address2, city: city, state: state, postcode: postcode, country: country, phone: phone, email: email) + } } diff --git a/Networking/Networking/Model/Order.swift b/Networking/Networking/Model/Order.swift index 7dc7a99629b..ad96d893eb2 100644 --- a/Networking/Networking/Model/Order.swift +++ b/Networking/Networking/Model/Order.swift @@ -27,8 +27,8 @@ public struct Order: Decodable { public let paymentMethodTitle: String public let items: [OrderItem] - public let billingAddress: Address - public let shippingAddress: Address + public let billingAddress: Address? + public let shippingAddress: Address? public let coupons: [OrderCouponLine] /// Order struct initializer. @@ -52,8 +52,8 @@ public struct Order: Decodable { totalTax: String, paymentMethodTitle: String, items: [OrderItem], - billingAddress: Address, - shippingAddress: Address, + billingAddress: Address?, + shippingAddress: Address?, coupons: [OrderCouponLine]) { self.siteID = siteID @@ -116,11 +116,13 @@ public struct Order: Decodable { let paymentMethodTitle = try container.decode(String.self, forKey: .paymentMethodTitle) let items = try container.decode([OrderItem].self, forKey: .items) - let shippingAddress = try container.decode(Address.self, forKey: .shippingAddress) - let billingAddress = try container.decode(Address.self, forKey: .billingAddress) + + let shippingAddress = try? container.decode(Address.self, forKey: .shippingAddress) + let billingAddress = try? container.decode(Address.self, forKey: .billingAddress) + let coupons = try container.decode([OrderCouponLine].self, forKey: .couponLines) - self.init(siteID: siteID, orderID: orderID, parentID: parentID, customerID: customerID, number: number, status: status, currency: currency, customerNote: customerNote, dateCreated: dateCreated, dateModified: dateModified, datePaid: datePaid, discountTotal: discountTotal, discountTax: discountTax, shippingTotal: shippingTotal, shippingTax: shippingTax, total: total, totalTax: totalTax, paymentMethodTitle: paymentMethodTitle, items: items, billingAddress: billingAddress, shippingAddress: shippingAddress, coupons: coupons) // initialize the struct + self.init(siteID: siteID, orderID: orderID, parentID: parentID, customerID: customerID, number: number, status: status, currency: currency, customerNote: customerNote, dateCreated: dateCreated, dateModified: dateModified, datePaid: datePaid, discountTotal: discountTotal, discountTax: discountTax, shippingTotal: shippingTotal, shippingTax: shippingTax, total: total, totalTax: totalTax, paymentMethodTitle: paymentMethodTitle, items: items, billingAddress: billingAddress, shippingAddress: shippingAddress, coupons: coupons) } } diff --git a/Networking/Networking/Model/OrderStatus.swift b/Networking/Networking/Model/OrderStatus.swift index f3b8f55d79c..431a9739f1e 100644 --- a/Networking/Networking/Model/OrderStatus.swift +++ b/Networking/Networking/Model/OrderStatus.swift @@ -64,6 +64,7 @@ extension OrderStatus: RawRepresentable { extension OrderStatus: CustomStringConvertible { /// Returns a string describing the current OrderStatus Instance + /// Custom doesn't return a localized string because the payload arrives at runtime, not buildtime. /// public var description: String { switch self { @@ -74,7 +75,10 @@ extension OrderStatus: CustomStringConvertible { case .cancelled: return NSLocalizedString("Canceled", comment: "Cancelled Order Status") case .completed: return NSLocalizedString("Completed", comment: "Completed Order Status") case .refunded: return NSLocalizedString("Refunded", comment: "Refunded Order Status") - case .custom(let payload): return NSLocalizedString("\(payload)", comment: "Custom Order Status") + case .custom(let payload): + return payload + .replacingOccurrences(of: "-", with: " ") + .capitalized } } } diff --git a/Networking/Networking/Model/Stats/OrderStats.swift b/Networking/Networking/Model/Stats/OrderStats.swift index 22966df86d8..02b62840eb7 100644 --- a/Networking/Networking/Model/Stats/OrderStats.swift +++ b/Networking/Networking/Model/Stats/OrderStats.swift @@ -8,14 +8,14 @@ public struct OrderStats: Decodable { public let granularity: StatGranularity public let quantity: String public let fields: [String] - public let totalGrossSales: Float - public let totalNetSales: Float + public let totalGrossSales: Double + public let totalNetSales: Double public let totalOrders: Int public let totalProducts: Int - public let averageGrossSales: Float - public let averageNetSales: Float - public let averageOrders: Float - public let averageProducts: Float + public let averageGrossSales: Double + public let averageNetSales: Double + public let averageOrders: Double + public let averageProducts: Double public let items: [OrderStatsItem]? @@ -31,15 +31,15 @@ public struct OrderStats: Decodable { let fields = try container.decode([String].self, forKey: .fields) let rawData: [[AnyCodable]] = try container.decode([[AnyCodable]].self, forKey: .data) - let totalGrossSales = try container.decode(Float.self, forKey: .totalGrossSales) - let totalNetSales = try container.decode(Float.self, forKey: .totalNetSales) + let totalGrossSales = try container.decode(Double.self, forKey: .totalGrossSales) + let totalNetSales = try container.decode(Double.self, forKey: .totalNetSales) let totalOrders = try container.decode(Int.self, forKey: .totalOrders) let totalProducts = try container.decode(Int.self, forKey: .totalProducts) - let averageGrossSales = try container.decode(Float.self, forKey: .averageGrossSales) - let averageNetSales = try container.decode(Float.self, forKey: .averageNetSales) - let averageOrders = try container.decode(Float.self, forKey: .averageOrders) - let averageProducts = try container.decode(Float.self, forKey: .averageProducts) + let averageGrossSales = try container.decode(Double.self, forKey: .averageGrossSales) + let averageNetSales = try container.decode(Double.self, forKey: .averageNetSales) + let averageOrders = try container.decode(Double.self, forKey: .averageOrders) + let averageProducts = try container.decode(Double.self, forKey: .averageProducts) let items = rawData.map({ OrderStatsItem(fieldNames: fields, rawData: $0) }) @@ -49,7 +49,7 @@ public struct OrderStats: Decodable { /// OrderStats struct initializer. /// - public init(date: String, granularity: StatGranularity, quantity: String, fields: [String], items: [OrderStatsItem]?, totalGrossSales: Float, totalNetSales: Float, totalOrders: Int, totalProducts: Int, averageGrossSales: Float, averageNetSales: Float, averageOrders: Float, averageProducts: Float) { + public init(date: String, granularity: StatGranularity, quantity: String, fields: [String], items: [OrderStatsItem]?, totalGrossSales: Double, totalNetSales: Double, totalOrders: Int, totalProducts: Int, averageGrossSales: Double, averageNetSales: Double, averageOrders: Double, averageProducts: Double) { self.date = date self.granularity = granularity self.quantity = quantity diff --git a/Networking/Networking/Model/Stats/SiteVisitStats.swift b/Networking/Networking/Model/Stats/SiteVisitStats.swift index ea9b584426e..8e6facf5bd8 100644 --- a/Networking/Networking/Model/Stats/SiteVisitStats.swift +++ b/Networking/Networking/Model/Stats/SiteVisitStats.swift @@ -34,6 +34,12 @@ public struct SiteVisitStats: Decodable { self.fields = fields self.items = items } + + // MARK: Computed Properties + + public var totalVisitors: Int { + return items?.map({ $0.visitors }).reduce(0, +) ?? 0 + } } diff --git a/Networking/Networking/Model/Stats/StatGranularity.swift b/Networking/Networking/Model/Stats/StatGranularity.swift index f9132193584..c8ab624d3f8 100644 --- a/Networking/Networking/Model/Stats/StatGranularity.swift +++ b/Networking/Networking/Model/Stats/StatGranularity.swift @@ -8,6 +8,20 @@ public enum StatGranularity: String, Decodable { case week case month case year + + public var pluralizedString: String { + switch self { + case .day: + return NSLocalizedString("Days", comment: "Plural of 'day' — a statistical unit") + case .week: + return NSLocalizedString("Weeks", comment: "Plural of 'week' — a statistical unit") + case .month: + return NSLocalizedString("Months", comment: "Plural of 'month' — a statistical unit") + case .year: + return NSLocalizedString("Years", comment: "Plural of 'year' — a statistical unit") + } + } + } // MARK: - StringConvertible Conformance @@ -19,13 +33,13 @@ extension StatGranularity: CustomStringConvertible { public var description: String { switch self { case .day: - return NSLocalizedString("Day", comment: "Order statistics unit - a single day") + return NSLocalizedString("Day", comment: "Statistical unit - a single day") case .week: - return NSLocalizedString("Week", comment: "Order statistics unit - a single week") + return NSLocalizedString("Week", comment: "Statistical unit - a single week") case .month: - return NSLocalizedString("Month", comment: "Order statistics unit - a single week") + return NSLocalizedString("Month", comment: "Statistical unit - a single week") case .year: - return NSLocalizedString("Year", comment: "Order statistics unit - a single year") + return NSLocalizedString("Year", comment: "Statistical unit - a single year") } } } diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index cda42cbded5..aad62b45324 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -32,8 +32,8 @@ public class AlamofireNetwork: Network { Alamofire.request(authenticated) .validate() .responseJSON { response in - completion(response.result.value, response.result.error) - } + completion(response.value, response.customizedError) + } } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -51,7 +51,30 @@ public class AlamofireNetwork: Network { Alamofire.request(authenticated) .validate() .responseData { response in - completion(response.result.value, response.result.error) + completion(response.value, response.customizedError) + } + } +} + + +/// MARK: - Alamofire.DataResponse: Private Methods +/// +private extension Alamofire.DataResponse { + + /// Returns `NetworkError.notFound` whenever the Request failed with a 404 StatusCode. This may be used by upper layers, + /// to determine if an object should be deleted (for instance!). + /// + /// In any other case, this property will actually return the regular `DataResponse.error` result. + /// + var customizedError: Error? { + guard result.isFailure else { + return nil } + + guard response?.statusCode == HTTPStatusCode.notFound else { + return error + } + + return NetworkError.notFound } } diff --git a/Networking/Networking/Network/HTTPStatusCode.swift b/Networking/Networking/Network/HTTPStatusCode.swift new file mode 100644 index 00000000000..2801e6b14c8 --- /dev/null +++ b/Networking/Networking/Network/HTTPStatusCode.swift @@ -0,0 +1,15 @@ +import Foundation + + +/// Represents (a seriously small subset of) the valid HTTP Status Code(s) +/// +public enum HTTPStatusCode { + + /// All Good! + /// + static let success = 200 + + /// Resource Not Found + /// + static let notFound = 404 +} diff --git a/Networking/Networking/Network/MockupNetwork.swift b/Networking/Networking/Network/MockupNetwork.swift index 6e0e0ae85d1..17a964b3e53 100644 --- a/Networking/Networking/Network/MockupNetwork.swift +++ b/Networking/Networking/Network/MockupNetwork.swift @@ -10,6 +10,10 @@ class MockupNetwork: Network { /// private var responseMap = [String: String]() + /// Mapping between URL Suffix and Error responses. + /// + private var errorMap = [String: Error]() + /// Keeps a collection of all of the `responseJSON` requests. /// var requestsForResponseJSON = [URLRequestConvertible]() @@ -20,6 +24,7 @@ class MockupNetwork: Network { + /// Public Initializer /// required init(credentials: Credentials) { } @@ -38,12 +43,17 @@ class MockupNetwork: Network { func responseJSON(for request: URLRequestConvertible, completion: @escaping (Any?, Error?) -> Void) { requestsForResponseJSON.append(request) - guard let filename = filename(for: request), let response = Loader.jsonObject(for: filename) else { - completion(nil, NetworkError.emptyResponse) + if let error = error(for: request) { + completion(nil, error) + return + } + + if let filename = filename(for: request), let response = Loader.jsonObject(for: filename) { + completion(response, nil) return } - completion(response, nil) + completion(nil, NetworkError.unknown) } /// Whenever the Request's URL matches any of the "Mocked Up Patterns", we'll return the specified response file, loaded as *Data*. @@ -52,12 +62,17 @@ class MockupNetwork: Network { func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { requestsForResponseData.append(request) - guard let filename = filename(for: request), let data = Loader.contentsOf(filename) else { - completion(nil, NetworkError.emptyResponse) + if let error = error(for: request) { + completion(nil, error) return } - completion(data, nil) + if let filename = filename(for: request), let data = Loader.contentsOf(filename) { + completion(data, nil) + return + } + + completion(nil, NetworkError.unknown) } } @@ -73,10 +88,17 @@ extension MockupNetwork { responseMap[requestUrlSuffix] = filename } + /// We'll return the specified Error, whenever a request matches the specified Suffix Criteria! + /// + func simulateError(requestUrlSuffix: String, error: Error) { + errorMap[requestUrlSuffix] = error + } + /// Removes all of the stored Simulated Responses. /// func removeAllSimulatedResponses() { responseMap.removeAll() + errorMap.removeAll() } /// Returns the Mockup JSON Filename for a given URLRequestConvertible. @@ -90,6 +112,17 @@ extension MockupNetwork { return nil } + /// Returns the Mockup Error for a given URLRequestConvertible. + /// + private func error(for request: URLRequestConvertible) -> Error? { + let searchPath = path(for: request) + for (pattern, error) in errorMap where searchPath.hasSuffix(pattern) { + return error + } + + return nil + } + /// Returns the "Request Path" for a given URLRequestConvertible instance. /// private func path(for request: URLRequestConvertible) -> String { diff --git a/Networking/Networking/Network/Network.swift b/Networking/Networking/Network/Network.swift index d23e03baa65..4527af9d3a5 100644 --- a/Networking/Networking/Network/Network.swift +++ b/Networking/Networking/Network/Network.swift @@ -34,9 +34,13 @@ public protocol Network { /// Networking Errors /// -enum NetworkError: Error { +public enum NetworkError: Error { - /// Indicates that there's not been an actual response! + /// Something went wrong. But we have no idea what it was! /// - case emptyResponse + case unknown + + /// Resource Not Found (statusCode = 404) + /// + case notFound } diff --git a/Networking/Networking/Remote/Remote.swift b/Networking/Networking/Remote/Remote.swift index f0b47e3db96..a3aa96c5734 100644 --- a/Networking/Networking/Remote/Remote.swift +++ b/Networking/Networking/Remote/Remote.swift @@ -31,7 +31,7 @@ public class Remote { func enqueue(_ request: URLRequestConvertible, completion: @escaping (Any?, Error?) -> Void) { network.responseJSON(for: request) { (payload, error) in guard let payload = payload else { - completion(nil, error ?? NetworkError.emptyResponse) + completion(nil, error) return } @@ -53,7 +53,7 @@ public class Remote { func enqueue(_ request: URLRequestConvertible, mapper: M, completion: @escaping (M.Output?, Error?) -> Void) { network.responseData(for: request) { (data, error) in guard let data = data else { - completion(nil, error ?? NetworkError.emptyResponse) + completion(nil, error) return } diff --git a/Networking/NetworkingTests/Mapper/OrderListMapperTests.swift b/Networking/NetworkingTests/Mapper/OrderListMapperTests.swift index 00336d414b6..c15199799ee 100644 --- a/Networking/NetworkingTests/Mapper/OrderListMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/OrderListMapperTests.swift @@ -55,7 +55,14 @@ class OrderListMapperTests: XCTestCase { XCTAssert(orders.count == 3) let firstOrder = orders[0] - let dummyAddresses = [firstOrder.billingAddress, firstOrder.shippingAddress] + var dummyAddresses = [Address]() + if let shippingAddress = firstOrder.shippingAddress { + dummyAddresses.append(shippingAddress) + } + + if let billingAddress = firstOrder.billingAddress { + dummyAddresses.append(billingAddress) + } for address in dummyAddresses { XCTAssertEqual(address.firstName, "Johnny") diff --git a/Networking/NetworkingTests/Mapper/OrderMapperTests.swift b/Networking/NetworkingTests/Mapper/OrderMapperTests.swift index 789d37202ad..226066ed355 100644 --- a/Networking/NetworkingTests/Mapper/OrderMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/OrderMapperTests.swift @@ -50,7 +50,8 @@ class OrderMapperTests: XCTestCase { return } - let dummyAddresses = [order.billingAddress, order.shippingAddress] + let dummyAddresses = [order.shippingAddress, order.billingAddress].compactMap({ $0 }) + XCTAssertEqual(dummyAddresses.count, 2) for address in dummyAddresses { XCTAssertEqual(address.firstName, "Johnny") diff --git a/Networking/NetworkingTests/Mapper/OrderStatsMapperTests.swift b/Networking/NetworkingTests/Mapper/OrderStatsMapperTests.swift index 24a85b80106..b623801beb9 100644 --- a/Networking/NetworkingTests/Mapper/OrderStatsMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/OrderStatsMapperTests.swift @@ -84,7 +84,7 @@ class OrderStatsMapperTests: XCTestCase { XCTAssertEqual(weekStats.totalOrders, 65) XCTAssertEqual(weekStats.totalProducts, 87) XCTAssertEqual(weekStats.totalGrossSales, 2858.52) - XCTAssertEqual(weekStats.totalNetSales, 2833.55) + XCTAssertEqual(weekStats.totalNetSales, 2833.5499999999997) XCTAssertEqual(weekStats.averageGrossSales, 92.2103) XCTAssertEqual(weekStats.averageNetSales, 91.4048) XCTAssertEqual(weekStats.averageOrders, 2.0968) diff --git a/Networking/NetworkingTests/Mapper/SiteVisitStatsMapperTests.swift b/Networking/NetworkingTests/Mapper/SiteVisitStatsMapperTests.swift index e9f381397ec..e58b0ea76a1 100644 --- a/Networking/NetworkingTests/Mapper/SiteVisitStatsMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/SiteVisitStatsMapperTests.swift @@ -18,6 +18,7 @@ class SiteVisitStatsMapperTests: XCTestCase { XCTAssertEqual(dayStats.date, "2018-08-06") XCTAssertEqual(dayStats.fields.count, 7) XCTAssertEqual(dayStats.items!.count, 12) + XCTAssertEqual(dayStats.totalVisitors, 105) let sampleItem1 = dayStats.items![0] XCTAssertEqual(sampleItem1.period, "2018-07-26") @@ -50,6 +51,7 @@ class SiteVisitStatsMapperTests: XCTestCase { XCTAssertEqual(weekStats.date, "2018-08-06") XCTAssertEqual(weekStats.fields.count, 7) XCTAssertEqual(weekStats.items!.count, 12) + XCTAssertEqual(weekStats.totalVisitors, 123123241) let sampleItem1 = weekStats.items![0] XCTAssertEqual(sampleItem1.period, "2018W05W21") @@ -83,11 +85,12 @@ class SiteVisitStatsMapperTests: XCTestCase { XCTAssertEqual(monthStats.date, "2018-08-06") XCTAssertEqual(monthStats.fields.count, 7) XCTAssertEqual(monthStats.items!.count, 12) + XCTAssertEqual(monthStats.totalVisitors, 292) let sampleItem1 = monthStats.items![0] XCTAssertEqual(sampleItem1.period, "2017-09-01") XCTAssertEqual(sampleItem1.views, 36) - XCTAssertEqual(sampleItem1.visitors, 24) + XCTAssertEqual(sampleItem1.visitors, 224) XCTAssertEqual(sampleItem1.likes, 1) XCTAssertEqual(sampleItem1.reblogs, 0) XCTAssertEqual(sampleItem1.comments, 3) @@ -115,11 +118,12 @@ class SiteVisitStatsMapperTests: XCTestCase { XCTAssertEqual(yearStats.date, "2018-08-06") XCTAssertEqual(yearStats.fields.count, 7) XCTAssertEqual(yearStats.items!.count, 5) + XCTAssertEqual(yearStats.totalVisitors, 3336) let sampleItem1 = yearStats.items![0] XCTAssertEqual(sampleItem1.period, "2014-01-01") XCTAssertEqual(sampleItem1.views, 12821) - XCTAssertEqual(sampleItem1.visitors, 1135) + XCTAssertEqual(sampleItem1.visitors, 1145) XCTAssertEqual(sampleItem1.likes, 1094) XCTAssertEqual(sampleItem1.reblogs, 0) XCTAssertEqual(sampleItem1.comments, 1611) diff --git a/Networking/NetworkingTests/Remote/RemoteTests.swift b/Networking/NetworkingTests/Remote/RemoteTests.swift index 0571ee05d7d..c775839894d 100644 --- a/Networking/NetworkingTests/Remote/RemoteTests.swift +++ b/Networking/NetworkingTests/Remote/RemoteTests.swift @@ -21,7 +21,7 @@ class RemoteTests: XCTestCase { remote.enqueue(request) { (payload, error) in XCTAssertNil(payload) - XCTAssertEqual(error as! NetworkError, NetworkError.emptyResponse) + XCTAssertEqual(error as! NetworkError, NetworkError.unknown) XCTAssert(network.requestsForResponseData.isEmpty) XCTAssert(network.requestsForResponseJSON.count == 1) @@ -46,7 +46,7 @@ class RemoteTests: XCTestCase { remote.enqueue(request, mapper: mapper) { (payload, error) in XCTAssertNil(payload) - XCTAssertEqual(error as! NetworkError, NetworkError.emptyResponse) + XCTAssertEqual(error as! NetworkError, NetworkError.unknown) XCTAssert(network.requestsForResponseJSON.isEmpty) XCTAssert(network.requestsForResponseData.count == 1) diff --git a/Networking/NetworkingTests/Responses/site-visits-month.json b/Networking/NetworkingTests/Responses/site-visits-month.json index 766e664409b..a16a4f4bacf 100644 --- a/Networking/NetworkingTests/Responses/site-visits-month.json +++ b/Networking/NetworkingTests/Responses/site-visits-month.json @@ -14,7 +14,7 @@ [ "2017-09-01", 36, - 24, + 224, 1, 0, 3, diff --git a/Networking/NetworkingTests/Responses/site-visits-week.json b/Networking/NetworkingTests/Responses/site-visits-week.json index 69db25ab63f..387c3beae9d 100644 --- a/Networking/NetworkingTests/Responses/site-visits-week.json +++ b/Networking/NetworkingTests/Responses/site-visits-week.json @@ -104,7 +104,7 @@ [ "2018W07W30", 2, - 2, + 100, 0, 0, 0, diff --git a/Networking/NetworkingTests/Responses/site-visits-year.json b/Networking/NetworkingTests/Responses/site-visits-year.json index 063fa54885f..d12b7e4244c 100644 --- a/Networking/NetworkingTests/Responses/site-visits-year.json +++ b/Networking/NetworkingTests/Responses/site-visits-year.json @@ -14,7 +14,7 @@ [ "2014-01-01", 12821, - 1135, + 1145, 1094, 0, 1611, diff --git a/Podfile b/Podfile index 71421bcd2be..a85372a11d3 100644 --- a/Podfile +++ b/Podfile @@ -18,7 +18,7 @@ target 'WooCommerce' do # pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :tag => '0.2.3' pod 'Gridicons', '0.15' - pod 'WordPressAuthenticator', '1.0.5' + pod 'WordPressAuthenticator', '1.0.6' pod 'WordPressShared', '1.0.8' @@ -29,6 +29,8 @@ target 'WooCommerce' do pod 'Crashlytics', '~> 3.10' pod 'KeychainAccess', '~> 3.1' pod 'CocoaLumberjack/Swift', '~> 3.4' + pod 'XLPagerTabStrip', '~> 8.0' + pod 'Charts', '~> 3.1' # Unit Tests # ========== diff --git a/Podfile.lock b/Podfile.lock index cffa4cffb0f..dde98ce35a9 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -5,6 +5,9 @@ PODS: - CocoaLumberjack (~> 3.4.1) - Reachability (~> 3.1) - UIDeviceIdentifier (~> 0.4) + - Charts (3.1.1): + - Charts/Core (= 3.1.1) + - Charts/Core (3.1.1) - CocoaLumberjack (3.4.2): - CocoaLumberjack/Default (= 3.4.2) - CocoaLumberjack/Extensions (= 3.4.2) @@ -13,9 +16,9 @@ PODS: - CocoaLumberjack/Default - CocoaLumberjack/Swift (3.4.2): - CocoaLumberjack/Default - - Crashlytics (3.10.5): - - Fabric (~> 1.7.9) - - Fabric (1.7.9) + - Crashlytics (3.10.7): + - Fabric (~> 1.7.11) + - Fabric (1.7.11) - FormatterKit/Resources (1.8.2) - FormatterKit/TimeIntervalFormatter (1.8.2): - FormatterKit/Resources @@ -38,7 +41,7 @@ PODS: - Reachability (3.2) - SVProgressHUD (2.2.5) - UIDeviceIdentifier (0.5.0) - - WordPressAuthenticator (1.0.5): + - WordPressAuthenticator (1.0.6): - 1PasswordExtension (= 1.8.5) - Alamofire (= 4.7.2) - CocoaLumberjack (= 3.4.2) @@ -64,21 +67,25 @@ PODS: - FormatterKit/TimeIntervalFormatter (= 1.8.2) - WordPressUI (1.0.6) - wpxmlrpc (0.8.3) + - XLPagerTabStrip (8.0.1) DEPENDENCIES: - Alamofire (~> 4.7) - Automattic-Tracks-iOS (from `https://github.com/Automattic/Automattic-Tracks-iOS.git`, tag `0.2.3`) + - Charts (~> 3.1) - CocoaLumberjack/Swift (~> 3.4) - Crashlytics (~> 3.10) - Gridicons (= 0.15) - KeychainAccess (~> 3.1) - - WordPressAuthenticator (= 1.0.5) + - WordPressAuthenticator (= 1.0.6) - WordPressShared (= 1.0.8) + - XLPagerTabStrip (~> 8.0) SPEC REPOS: https://github.com/cocoapods/specs.git: - 1PasswordExtension - Alamofire + - Charts - CocoaLumberjack - Crashlytics - Fabric @@ -98,6 +105,7 @@ SPEC REPOS: - WordPressShared - WordPressUI - wpxmlrpc + - XLPagerTabStrip EXTERNAL SOURCES: Automattic-Tracks-iOS: @@ -113,9 +121,10 @@ SPEC CHECKSUMS: 1PasswordExtension: 0e95bdea64ec8ff2f4f693be5467a09fac42a83d Alamofire: e4fa87002c137ba2d8d634d2c51fabcda0d5c223 Automattic-Tracks-iOS: d8c6c6c1351b1905a73e45f431b15598d71963b5 + Charts: 90a4d61da0f6e06684c591e3bcab11940fe61736 CocoaLumberjack: db7cc9e464771f12054c22ff6947c5a58d43a0fd - Crashlytics: 7f2e38d302d9da96475b3d64d86fb29e31a542b7 - Fabric: a2917d3895e4c1569b9c3170de7320ea1b1e6661 + Crashlytics: ccaac42660eb9351b9960c0d66106b0bcf99f4fa + Fabric: f233c9492b3bbc1f04e3882986740f7988a58edb FormatterKit: 4b8f29acc9b872d5d12a63efb560661e8f2e1b98 GoogleSignInRepacked: d357702618c555f38923576924661325eb1ef22b GoogleToolboxForMac: 91c824d21e85b31c2aae9bb011c5027c9b4e738f @@ -127,12 +136,13 @@ SPEC CHECKSUMS: Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6 UIDeviceIdentifier: a959a6d4f51036b4180dd31fb26483a820f1cc46 - WordPressAuthenticator: e6e1c80aff95f1b2ad11e4477fcfe9f61aa8c49a + WordPressAuthenticator: 56538a229185640b41912c10c3f1891c2cc9bbb9 WordPressKit: a4a3849684f631a3abf579f6d3f15a32677cbb30 WordPressShared: 063e1e8b1a7aaf635abf17f091a2d235a068abdc WordPressUI: af141587ec444f9af753a00605bd0d3f14d8d8a3 wpxmlrpc: bfc572f62ce7ee897f6f38b098d2ba08732ecef4 + XLPagerTabStrip: c908b17cbf42fcd2598ee1adfc49bae25444d88a -PODFILE CHECKSUM: 9c0f38b4e1f7d6de164e2c4279a5112a7f95f936 +PODFILE CHECKSUM: 579bb6345aecc3f27e78d220e137a749e9ee5f08 COCOAPODS: 1.5.3 diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index 9f4ed10aac3..0f04f5d508d 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -130,6 +130,10 @@ private extension AppDelegate { UINavigationBar.appearance().isTranslucent = false UINavigationBar.appearance().tintColor = .white UIApplication.shared.statusBarStyle = .lightContent + + // Take advantage of a bug in UIAlertController + // to style all UIAlertControllers with WC color + window?.tintColor = StyleManager.wooCommerceBrandColor } /// Sets up FancyButton's UIAppearance. diff --git a/WooCommerce/Classes/Extensions/Date+Helpers.swift b/WooCommerce/Classes/Extensions/Date+Helpers.swift index 0b61e0add09..b88a7de5a4c 100644 --- a/WooCommerce/Classes/Extensions/Date+Helpers.swift +++ b/WooCommerce/Classes/Extensions/Date+Helpers.swift @@ -18,4 +18,50 @@ extension DateFormatter { return formatter }() } + + /// Chart Formatters + /// + struct Charts { + + /// Date formatter used for creating the date displayed on a chart axis for **day** granularity. + /// + public static let chartsDayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "MMM d" + return formatter + }() + + /// Date formatter used for creating the date displayed on a chart axis for **week** granularity. + /// + public static let chartsWeekFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "MMM d" + return formatter + }() + + /// Date formatter used for creating the date displayed on a chart axis for **month** granularity. + /// + public static let chartsMonthFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "MMM" + return formatter + }() + + /// Date formatter used for creating the date displayed on a chart axis for **year** granularity. + /// + public static let chartsYearFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "yyyy" + return formatter + }() + } + } diff --git a/WooCommerce/Classes/Extensions/Double+Woo.swift b/WooCommerce/Classes/Extensions/Double+Woo.swift new file mode 100644 index 00000000000..d52bf90d98a --- /dev/null +++ b/WooCommerce/Classes/Extensions/Double+Woo.swift @@ -0,0 +1,47 @@ +import Foundation + + +extension Double { + + /// Provides a short, friendly representation of the current Double value. If the value is + /// below 1000, the decimal is stripped and the string returned will look like an Int. If the value + /// is above 1000, the value is rounded to the nearest tenth and the appropriate abbreviation + /// will be appended (k, m, b, t). + /// + /// Examples: + /// - 0 becomes "0" + /// - 198.44 becomes "198" + /// - 999 becomes "999" + /// - 1000 becomes "1.0k" + /// - 999999 becomes "1.0m" + /// - 1000000 becomes "1.0m" + /// - 1000000000 becomes "1.0b" + /// - 1000000000000 becomes "1.0t" + /// - 5800199 becomes "5.8m" + /// + /// This helper function does work with negative values as well. + /// + func friendlyString() -> String { + var num = Double(self) + let sign = ((num < 0) ? "-" : "" ) + num = fabs(num) + if -1000.0..<1000.0 ~= num { + let intNum = Int(num) + if intNum == 0 { + return "\(Int(num))" + } + return "\(sign)\(Int(num))" + } + + let exp = Int(log10(num) / 3.0 ) // log10(1000) + let units = ["k", "m", "b", "t"] + let roundedNum: Double = Foundation.round(10 * num / pow(1000.0, Double(exp))) / 10 + + if roundedNum == 1000.0 { + return "\(sign)\(1.0)\(units[exp])" + } else { + return "\(sign)\(roundedNum)\(units[exp-1])" + } + } + +} diff --git a/WooCommerce/Classes/Extensions/UIButton+Helpers.swift b/WooCommerce/Classes/Extensions/UIButton+Helpers.swift index fe2348c2b29..50d5af0cf1a 100644 --- a/WooCommerce/Classes/Extensions/UIButton+Helpers.swift +++ b/WooCommerce/Classes/Extensions/UIButton+Helpers.swift @@ -7,6 +7,6 @@ extension UIButton { tintColor = .white layer.cornerRadius = 8.0 contentEdgeInsets = UIEdgeInsetsMake(16.0, 16.0, 16.0, 16.0) - titleLabel?.applyTitleStyle() + titleLabel?.applyHeadlineStyle() } } diff --git a/WooCommerce/Classes/Extensions/UILabel+Helpers.swift b/WooCommerce/Classes/Extensions/UILabel+Helpers.swift index 474f50fe89c..69b45c4fabd 100644 --- a/WooCommerce/Classes/Extensions/UILabel+Helpers.swift +++ b/WooCommerce/Classes/Extensions/UILabel+Helpers.swift @@ -3,7 +3,7 @@ import Yosemite extension UILabel { - func applyTitleStyle() { + func applyHeadlineStyle() { font = .headline textColor = StyleManager.defaultTextColor } @@ -14,7 +14,12 @@ extension UILabel { } func applyFootnoteStyle() { - font = UIFont.footnote + font = .footnote + textColor = StyleManager.defaultTextColor + } + + func applyTitleStyle() { + font = .title1 textColor = StyleManager.defaultTextColor } diff --git a/WooCommerce/Classes/Model/Order+Woo.swift b/WooCommerce/Classes/Model/Order+Woo.swift index 64cb6df4637..219e441dec4 100644 --- a/WooCommerce/Classes/Model/Order+Woo.swift +++ b/WooCommerce/Classes/Model/Order+Woo.swift @@ -14,4 +14,37 @@ extension Order { return NSLocale(localeIdentifier: identifier).currencySymbol } + + /// Translates a Section Identifier into a Human-Readable String. + /// + static func descriptionForSectionIdentifier(_ identifier: String) -> String { + guard let age = Age(rawValue: identifier) else { + return String() + } + + return age.description + } +} + +enum Age: String { + case months = "0" + case weeks = "2" + case days = "4" + case yesterday = "5" + case today = "6" + + var description: String { + switch self { + case .months: + return NSLocalizedString("Older than a Month", comment: "Notifications Months Section Header") + case .weeks: + return NSLocalizedString("Older than a Week", comment: "Notifications Weeks Section Header") + case .days: + return NSLocalizedString("Older than 2 days", comment: "Notifications +2 Days Section Header") + case .yesterday: + return NSLocalizedString("Yesterday", comment: "Notifications Yesterday Section Header") + case .today: + return NSLocalizedString("Today", comment: "Notifications Today Section Header") + } + } } diff --git a/WooCommerce/Classes/Model/OrderStats+Woo.swift b/WooCommerce/Classes/Model/OrderStats+Woo.swift new file mode 100644 index 00000000000..bfcaad462a9 --- /dev/null +++ b/WooCommerce/Classes/Model/OrderStats+Woo.swift @@ -0,0 +1,30 @@ +import Foundation +import Yosemite + + +// MARK: - OrderStats Helper Methods +// +extension OrderStats { + + /// Returns the Currency Symbol associated with the current order stats. + /// + var currencySymbol: String { + guard let currency = items?.filter({ !$0.currency.isEmpty }).first?.currency else { + return "" + } + guard let identifier = Locale.availableIdentifiers.first(where: { Locale(identifier: $0).currencyCode == currency }) else { + return currency + } + + return Locale(identifier: identifier).currencySymbol ?? currency + } + + /// Returns the sum of total sales this stats period. This value is typically used in the dashboard for revenue reporting. + /// + /// *Note:* The value returned here is an aggregation of all the `OrderStatsItem.totalSales` values and + /// _not_ `OrderStats.totalGrossSales` or `OrderStats.totalNetSales`. + /// + var totalSales: Double { + return items?.map({ $0.totalSales }).reduce(0.0, +) ?? 0.0 + } +} diff --git a/WooCommerce/Classes/Model/StorageOrder+Woo.swift b/WooCommerce/Classes/Model/StorageOrder+Woo.swift new file mode 100644 index 00000000000..4e2ef368ddf --- /dev/null +++ b/WooCommerce/Classes/Model/StorageOrder+Woo.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressShared +import Yosemite + +extension StorageOrder { + /// Returns a Section Identifier that can be sorted. Note that this string is not human readable, and + /// you should use the *descriptionForSectionIdentifier* method as well!. + /// + @objc func normalizedAgeAsString() -> String { + // Normalize Dates: Time must not be considered. Just the raw dates + guard let fromDate = dateCreated?.normalizedDate() else { + return "" + } + + let toDate = Date().normalizedDate() + + // Analyze the Delta-Components + let calendar = Calendar.current + let components = [.day, .weekOfYear, .month] as Set + let dateComponents = calendar.dateComponents(components, from: fromDate, to: toDate) + let identifier: Age + + // Months + if let month = dateComponents.month, month >= 1 { + identifier = .months + // Weeks + } else if let week = dateComponents.weekOfYear, week >= 1 { + identifier = .weeks + // Days + } else if let day = dateComponents.day, day > 1 { + identifier = .days + } else if let day = dateComponents.day, day == 1 { + identifier = .yesterday + } else { + identifier = .today + } + + return identifier.rawValue + } +} diff --git a/WooCommerce/Classes/Styles/Style.swift b/WooCommerce/Classes/Styles/Style.swift index 875a403a978..98a09f48d4a 100644 --- a/WooCommerce/Classes/Styles/Style.swift +++ b/WooCommerce/Classes/Styles/Style.swift @@ -16,6 +16,7 @@ protocol Style { var buttonDisabledHighlightedColor: UIColor { get } var buttonDisabledTitleColor: UIColor { get } var cellSeparatorColor: UIColor { get } + var chartLabelFont: UIFont { get } var defaultTextColor: UIColor { get } var destructiveActionColor: UIColor { get } var navBarImage: UIImage { get } @@ -38,45 +39,83 @@ protocol Style { var wooGreyTextMin: UIColor { get } var wooGreyBorder: UIColor { get } var wooSecondary: UIColor { get } + var wooWhite: UIColor { get } } + // MARK: - WooCommerce's Default Style // class DefaultStyle: Style { - let actionButtonTitleFont = UIFont.font(forStyle: .headline, weight: .semibold) - let alternativeLoginsTitleFont = UIFont.font(forStyle: .subheadline, weight: .semibold) - let buttonPrimaryColor = UIColor(red: 0x96/255.0, green: 0x58/255.0, blue: 0x8A/255.0, alpha: 0xFF/255.0) - let buttonPrimaryHighlightedColor = UIColor(red: 0x6E/255.0, green: 0x29/255.0, blue: 0x67/255.0, alpha: 0xFF/255.0) - let buttonPrimaryTitleColor = UIColor.white - let buttonSecondaryColor = UIColor.white - let buttonSecondaryHighlightedColor = UIColor.gray - let buttonSecondaryTitleColor = UIColor.gray - let buttonDisabledColor = UIColor.white - let buttonDisabledHighlightedColor = UIColor(red: 233.0/255.0, green: 239.0/255.0, blue: 234.0/255.0, alpha: 1.0) - let buttonDisabledTitleColor = UIColor(red: 233.0/255.0, green: 239.0/255.0, blue: 234.0/255.0, alpha: 1.0) - let cellSeparatorColor = UIColor.lightGray - let defaultTextColor = UIColor.black - let destructiveActionColor = UIColor(red: 197.0/255.0, green: 60.0/255.0, blue: 53.0/255.0, alpha: 1.0) - let navBarImage = UIImage(named: "woo-logo")! - let sectionBackgroundColor = UIColor(red: 239.0/255.0, green: 239.0/255.0, blue: 244.0/255.0, alpha: 1.0) - let sectionTitleColor = UIColor.darkGray - let statusDangerColor = UIColor(red: 255.0/255.0, green: 230.0/255.0, blue: 229.0/255.0, alpha: 1.0) - let statusDangerBoldColor = UIColor(red: 255.0/255.0, green: 197.0/255.0, blue: 195.0/255.0, alpha: 1.0) - let statusNotIdentifiedColor = UIColor(red: 235.0/255.0, green: 235.0/255.0, blue: 235.0/255.0, alpha: 1.0) - let statusNotIdentifiedBoldColor = UIColor(red: 226.0/255.0, green: 226.0/255.0, blue: 226.0/255.0, alpha: 1.0) - let statusPrimaryColor = UIColor(red: 244.0/255.0, green: 249.0/255.0, blue: 251.0/255.0, alpha: 1.0) - let statusPrimaryBoldColor = UIColor(red: 188.0/255.0, green: 222.0/255.0, blue: 238.0/255.0, alpha: 1.0) - let statusSuccessColor = UIColor(red: 239.00/255.0, green: 249.0/255.0, blue: 230.0/255.0, alpha: 1.0) - let statusSuccessBoldColor = UIColor(red: 201.0/255.0, green: 233.0/255.0, blue: 169.0/255.0, alpha: 1.0) - let subheadlineFont = UIFont.font(forStyle: .subheadline, weight: .regular) - let tableViewBackgroundColor = UIColor(red: 247.0/255.0, green: 247.0/255.0, blue: 247.0/255.0, alpha: 1.0) - let wooCommerceBrandColor = UIColor(red: 0x96/255.0, green: 0x58/255.0, blue: 0x8A/255.0, alpha: 0xFF/255.0) - let wooAccent = UIColor(red: 113.0/255.0, green: 176.0/255.0, blue: 47.0/255.0, alpha: 1.0) - let wooGreyLight = UIColor(red: 247.0/255.0, green: 247.0/255.0, blue: 247.0/255.0, alpha: 1.0) - let wooGreyMid = UIColor(red: 150.0/255.0, green: 150.0/255.0, blue: 150.0/255.0, alpha: 1.0) - let wooGreyTextMin = UIColor(red: 89.0/255.0, green: 89.0/255.0, blue: 89.0/255.0, alpha: 1.0) - let wooGreyBorder = UIColor(red: 230.0/255.0, green: 230.0/255.0, blue: 230.0/255.0, alpha: 1.0) - let wooSecondary = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 60.0/255.0, alpha: 1.0) + + // Fonts! + // + let actionButtonTitleFont = UIFont.font(forStyle: .headline, weight: .semibold) + let alternativeLoginsTitleFont = UIFont.font(forStyle: .subheadline, weight: .semibold) + let subheadlineFont = UIFont.font(forStyle: .subheadline, weight: .regular) + let chartLabelFont = UIFont.font(forStyle: .caption2, weight: .ultraLight) + + // Colors! + // + let buttonPrimaryColor = UIColor(red: 0x96/255.0, green: 0x58/255.0, blue: 0x8A/255.0, alpha: 0xFF/255.0) + let buttonPrimaryHighlightedColor = UIColor(red: 0x6E/255.0, green: 0x29/255.0, blue: 0x67/255.0, alpha: 0xFF/255.0) + let buttonPrimaryTitleColor = HandbookColors.wooWhite + let buttonSecondaryColor = HandbookColors.wooWhite + let buttonSecondaryHighlightedColor = HandbookColors.wooGreyMid + let buttonSecondaryTitleColor = HandbookColors.wooGreyMid + let buttonDisabledColor = HandbookColors.wooWhite + let buttonDisabledHighlightedColor = UIColor(red: 233.0/255.0, green: 239.0/255.0, blue: 234.0/255.0, alpha: 1.0) + let buttonDisabledTitleColor = UIColor(red: 233.0/255.0, green: 239.0/255.0, blue: 234.0/255.0, alpha: 1.0) + let cellSeparatorColor = HandbookColors.wooGreyBorder + let defaultTextColor = HandbookColors.wooSecondary + let destructiveActionColor = UIColor(red: 197.0/255.0, green: 60.0/255.0, blue: 53.0/255.0, alpha: 1.0) + let navBarImage = UIImage(named: "woo-logo")! + let sectionBackgroundColor = HandbookColors.wooGreyLight + let sectionTitleColor = HandbookColors.wooSecondary + let tableViewBackgroundColor = HandbookColors.wooGreyLight + + let statusDangerColor = HandbookColors.statusRedDimmed + let statusDangerBoldColor = HandbookColors.statusRed + let statusNotIdentifiedColor = HandbookColors.wooGreyLight + let statusNotIdentifiedBoldColor = HandbookColors.wooGreyBorder + let statusPrimaryColor = HandbookColors.statusBlueDimmed + let statusPrimaryBoldColor = HandbookColors.statusBlue + let statusSuccessColor = HandbookColors.statusGreenDimmed + let statusSuccessBoldColor = HandbookColors.statusGreen + + let wooCommerceBrandColor = HandbookColors.wooPrimary + let wooSecondary = HandbookColors.wooSecondary + let wooAccent = HandbookColors.wooAccent + let wooGreyLight = HandbookColors.wooGreyLight + let wooGreyBorder = HandbookColors.wooGreyBorder + let wooGreyMid = HandbookColors.wooGreyMid + let wooGreyTextMin = HandbookColors.wooGreyTextMin + let wooWhite = HandbookColors.wooWhite +} + + +// MARK: - Handbook colors! +// +private extension DefaultStyle { + + /// Colors as defined in the Woo Mobile Design Handbook + /// + enum HandbookColors { + static let statusRedDimmed = UIColor(red: 255.0/255.0, green: 230.0/255.0, blue: 229.0/255.0, alpha: 1.0) + static let statusRed = UIColor(red: 255.0/255.0, green: 197.0/255.0, blue: 195.0/255.0, alpha: 1.0) + static let statusBlueDimmed = UIColor(red: 244.0/255.0, green: 249.0/255.0, blue: 251.0/255.0, alpha: 1.0) + static let statusBlue = UIColor(red: 188.0/255.0, green: 222.0/255.0, blue: 238.0/255.0, alpha: 1.0) + static let statusGreenDimmed = UIColor(red: 239.00/255.0, green: 249.0/255.0, blue: 230.0/255.0, alpha: 1.0) + static let statusGreen = UIColor(red: 201.0/255.0, green: 233.0/255.0, blue: 169.0/255.0, alpha: 1.0) + + static let wooPrimary = UIColor(red: 0x96/255.0, green: 0x58/255.0, blue: 0x8A/255.0, alpha: 0xFF/255.0) + static let wooSecondary = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 60.0/255.0, alpha: 1.0) + static let wooAccent = UIColor(red: 113.0/255.0, green: 176.0/255.0, blue: 47.0/255.0, alpha: 1.0) + static let wooGreyLight = UIColor(red: 247.0/255.0, green: 247.0/255.0, blue: 247.0/255.0, alpha: 1.0) + static let wooGreyBorder = UIColor(red: 230.0/255.0, green: 230.0/255.0, blue: 230.0/255.0, alpha: 1.0) + static let wooWhite = UIColor.white + static let wooGreyMid = UIColor(red: 150.0/255.0, green: 150.0/255.0, blue: 150.0/255.0, alpha: 1.0) + static let wooGreyTextMin = UIColor(red: 89.0/255.0, green: 89.0/255.0, blue: 89.0/255.0, alpha: 1.0) + } } @@ -145,6 +184,10 @@ class StyleManager { return active.cellSeparatorColor } + static var chartLabelFont: UIFont { + return active.chartLabelFont + } + static var defaultTextColor: UIColor { return active.defaultTextColor } @@ -209,6 +252,10 @@ class StyleManager { return active.wooCommerceBrandColor } + static var wooSecondary: UIColor { + return active.wooSecondary + } + static var wooAccent: UIColor { return active.wooAccent } @@ -217,6 +264,10 @@ class StyleManager { return active.wooGreyLight } + static var wooGreyBorder: UIColor { + return active.wooGreyBorder + } + static var wooGreyMid: UIColor { return active.wooGreyMid } @@ -225,11 +276,7 @@ class StyleManager { return active.wooGreyTextMin } - static var wooGreyBorder: UIColor { - return active.wooGreyBorder - } - - static var wooSecondary: UIColor { - return active.wooSecondary + static var wooWhite: UIColor { + return active.wooWhite } } diff --git a/WooCommerce/Classes/ViewModels/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/OrderDetailsViewModel.swift index 17235c0ff99..0c88c7ff58c 100644 --- a/WooCommerce/Classes/ViewModels/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/OrderDetailsViewModel.swift @@ -14,8 +14,11 @@ class OrderDetailsViewModel { self.orderStatusViewModel = OrderStatusViewModel(orderStatus: order.status) } - var summaryTitle: String { - return "#\(order.number) \(order.shippingAddress.firstName) \(order.shippingAddress.lastName)" + var summaryTitle: String? { + if let billingAddress = order.billingAddress { + return "#\(order.number) \(billingAddress.firstName) \(billingAddress.lastName)" + } + return "#\(order.number)" } var summaryDateCreated: String { @@ -59,15 +62,21 @@ class OrderDetailsViewModel { return order.customerNote ?? String() } - var shippingViewModel: ContactViewModel { - return ContactViewModel(with: order.shippingAddress, contactType: ContactType.shipping) - } - var shippingAddress: String? { - return shippingViewModel.formattedAddress + var shippingViewModel: ContactViewModel? { + if let shippingAddress = order.shippingAddress { + return ContactViewModel(with: shippingAddress, contactType: ContactType.shipping) + } + + return nil } - private(set) lazy var billingViewModel = ContactViewModel(with: order.billingAddress, contactType: ContactType.billing) - private(set) lazy var billingAddress = billingViewModel.formattedAddress + var billingViewModel: ContactViewModel? { + if let billingAddress = order.billingAddress { + return ContactViewModel(with: billingAddress, contactType: ContactType.billing) + } + + return nil + } let subtotalLabel = NSLocalizedString("Subtotal", comment: "Subtotal label for payment view") diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Dashboard.storyboard b/WooCommerce/Classes/ViewRelated/Dashboard/Dashboard.storyboard index 6ba8d70dd3b..c231a8c230a 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Dashboard.storyboard +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Dashboard.storyboard @@ -9,6 +9,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -16,19 +92,54 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift index c473ce721c8..f50a8dc419b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift @@ -1,12 +1,24 @@ import UIKit import Gridicons +import CocoaLumberjack // MARK: - DashboardViewController // class DashboardViewController: UIViewController { - // MARK: - View Lifecycle + // MARK: Properties + + @IBOutlet private weak var scrollView: UIScrollView! + private var storeStatsViewController: StoreStatsViewController! + + private lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged) + return refreshControl + }() + + // MARK: View Lifecycle required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) @@ -15,10 +27,30 @@ class DashboardViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - setupNavigation() + configureNavigation() + configureView() + reloadData() + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let vc = segue.destination as? StoreStatsViewController, segue.identifier == Constants.storeStatsSegue { + self.storeStatsViewController = vc + } + } + +} + + +// MARK: - Configuration +// +private extension DashboardViewController { + + func configureView() { + view.backgroundColor = StyleManager.tableViewBackgroundColor + scrollView.refreshControl = refreshControl } - func setupNavigation() { + func configureNavigation() { title = NSLocalizedString("My Store", comment: "Dashboard navigation title") let rightBarButton = UIBarButtonItem(image: Gridicon.iconOfType(.cog), style: .plain, @@ -35,18 +67,41 @@ class DashboardViewController: UIViewController { navigationItem.backBarButtonItem = backButton } +} + - // MARK: - Actions +// MARK: - Action Handlers +// +private extension DashboardViewController { @objc func settingsTapped() { performSegue(withIdentifier: Constants.settingsSegue, sender: nil) } + + @objc func pullToRefresh() { + // FIXME: This code is just a WIP + reloadData() + refreshControl.endRefreshing() + DDLogInfo("Reloading dashboard data.") + } } + +// MARK: - Private Helpers +// +private extension DashboardViewController { + func reloadData() { + // FIXME: This code is just a WIP + storeStatsViewController.syncAllStats() + } +} + + // MARK: - Constants // private extension DashboardViewController { struct Constants { - static let settingsSegue = "ShowSettingsViewController" + static let settingsSegue = "ShowSettingsViewController" + static let storeStatsSegue = "StoreStatsEmbedSeque" } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/ChartMarker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/ChartMarker.swift new file mode 100644 index 00000000000..64485b82c84 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/ChartMarker.swift @@ -0,0 +1,193 @@ +import Foundation +import Charts + + +/// This class is a custom view which is displayed over a chart element (e.g. a Bar) when it is highlighted. +/// +/// See: https://github.com/danielgindi/Charts/blob/master/ChartsDemo-iOS/Swift/Components/BalloonMarker.swift +/// +class ChartMarker: MarkerImage { + @objc open var color: UIColor + @objc open var arrowSize = Constants.arrowSize + @objc open var font: UIFont + @objc open var textColor: UIColor + @objc open var insets: UIEdgeInsets + @objc open var minimumSize = CGSize() + + private var label: String? + private var _labelSize: CGSize = CGSize() + private var _paragraphStyle: NSMutableParagraphStyle? + private var _drawAttributes = [NSAttributedStringKey: AnyObject]() + + @objc public init(chartView: ChartViewBase?, color: UIColor, font: UIFont, textColor: UIColor, insets: UIEdgeInsets) { + self.color = color + self.font = font + self.textColor = textColor + self.insets = insets + + _paragraphStyle = NSParagraphStyle.default.mutableCopy() as? NSMutableParagraphStyle + _paragraphStyle?.alignment = .center + super.init() + self.chartView = chartView + } + + open override func offsetForDrawing(atPoint point: CGPoint) -> CGPoint { + var offset = self.offset + var size = self.size + + if let image = image, size.width == 0.0 { + size.width = image.size.width + } + + if let image = image, size.height == 0.0 { + size.height = image.size.height + } + + let width = size.width + let height = size.height + let padding = Constants.offsetPadding + + var origin = point + origin.x -= width / 2 + origin.y -= height + + if (origin.x + offset.x) < 0.0 { + offset.x = -origin.x + padding + } else if let chart = chartView, (origin.x + width + offset.x) > chart.bounds.size.width { + offset.x = chart.bounds.size.width - origin.x - width - padding + } + + if (origin.y + offset.y) < 0 { + offset.y = height + padding + } else if let chart = chartView, (origin.y + height + offset.y) > chart.bounds.size.height { + offset.y = chart.bounds.size.height - origin.y - height - padding + } + + return CGPoint(x: round(offset.x), y: round(offset.y)) + } + + open override func draw(context: CGContext, point: CGPoint) { + guard let label = label else { + return + } + + let offset = self.offsetForDrawing(atPoint: point) + let size = self.size + + var rect = CGRect( + origin: CGPoint( + x: point.x + offset.x, + y: point.y + offset.y), + size: size) + rect.origin.x -= size.width / 2.0 + rect.origin.y -= size.height + rect = rect.integral + + context.saveGState() + context.setFillColor(color.cgColor) + + if offset.y > 0 { + context.beginPath() + context.move(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y + arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x + (rect.size.width - arrowSize.width) / 2.0, + y: rect.origin.y + arrowSize.height)) + + // Arrow vertex + context.addLine(to: CGPoint( + x: point.x, + y: point.y)) + context.addLine(to: CGPoint( + x: rect.origin.x + (rect.size.width + arrowSize.width) / 2.0, + y: rect.origin.y + arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x + rect.size.width, + y: rect.origin.y + arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x + rect.size.width, + y: rect.origin.y + rect.size.height)) + context.addLine(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y + rect.size.height)) + context.addLine(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y + arrowSize.height)) + context.fillPath() + } else { + context.beginPath() + context.move(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y)) + context.addLine(to: CGPoint( + x: rect.origin.x + rect.size.width, + y: rect.origin.y)) + context.addLine(to: CGPoint( + x: rect.origin.x + rect.size.width, + y: rect.origin.y + rect.size.height - arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x + (rect.size.width + arrowSize.width) / 2.0, + y: rect.origin.y + rect.size.height - arrowSize.height)) + + //Arrow vertex + context.addLine(to: CGPoint( + x: point.x, + y: point.y)) + context.addLine(to: CGPoint( + x: rect.origin.x + (rect.size.width - arrowSize.width) / 2.0, + y: rect.origin.y + rect.size.height - arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y + rect.size.height - arrowSize.height)) + context.addLine(to: CGPoint( + x: rect.origin.x, + y: rect.origin.y)) + context.fillPath() + } + + if offset.y > 0 { + rect.origin.y += self.insets.top + arrowSize.height + } else { + rect.origin.y += self.insets.top + } + rect.size.height -= self.insets.top + self.insets.bottom + rect = rect.integral + UIGraphicsPushContext(context) + label.draw(in: rect, withAttributes: _drawAttributes) + UIGraphicsPopContext() + context.restoreGState() + } + + open override func refreshContent(entry: ChartDataEntry, highlight: Highlight) { + let hintString = entry.accessibilityValue ?? String(entry.y) + setLabel(hintString) + } + + @objc open func setLabel(_ newLabel: String) { + label = newLabel + + _drawAttributes.removeAll() + _drawAttributes[.font] = self.font + _drawAttributes[.paragraphStyle] = _paragraphStyle + _drawAttributes[.foregroundColor] = self.textColor + _labelSize = label?.size(withAttributes: _drawAttributes) ?? CGSize.zero + + var size = CGSize() + size.width = _labelSize.width + self.insets.left + self.insets.right + size.height = _labelSize.height + self.insets.top + self.insets.bottom + size.width = max(minimumSize.width, size.width) + size.height = max(minimumSize.height, size.height) + self.size = size + } +} + + +// MARK: - Constants! +// +private extension ChartMarker { + enum Constants { + static let arrowSize = CGSize(width: 20, height: 14) + static let offsetPadding: CGFloat = 4.0 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.swift new file mode 100644 index 00000000000..3f95a4f8e91 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.swift @@ -0,0 +1,346 @@ +import UIKit +import Yosemite +import Charts +import XLPagerTabStrip +import CocoaLumberjack + + +class PeriodDataViewController: UIViewController, IndicatorInfoProvider { + + // MARK: - Properties + + @IBOutlet private weak var visitorsTitle: UILabel! + @IBOutlet private weak var visitorsData: UILabel! + @IBOutlet private weak var ordersTitle: UILabel! + @IBOutlet private weak var ordersData: UILabel! + @IBOutlet private weak var revenueTitle: UILabel! + @IBOutlet private weak var revenueData: UILabel! + @IBOutlet private weak var barChartView: BarChartView! + @IBOutlet private weak var lastUpdated: UILabel! + @IBOutlet private weak var borderView: UIView! + private var lastUpdatedDate: Date? + + public let granularity: StatGranularity + public var orderStats: OrderStats? { + didSet { + lastUpdatedDate = Date() + reloadOrderFields() + reloadChart() + reloadLastUpdatedField() + } + } + public var siteStats: SiteVisitStats? { + didSet { + lastUpdatedDate = Date() + reloadSiteFields() + reloadLastUpdatedField() + } + } + + // MARK: - Computed Properties + + private var summaryDateUpdated: String { + if let lastUpdatedDate = lastUpdatedDate { + return String.localizedStringWithFormat(NSLocalizedString("Updated %@", + comment: "Stats summary date"), lastUpdatedDate.mediumString()) + } else { + return "" + } + } + + // MARK: - Initialization + + /// Designated Initializer + /// + init(granularity: StatGranularity) { + self.granularity = granularity + super.init(nibName: type(of: self).nibName, bundle: nil) + } + + /// NSCoder Conformance + /// + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureView() + configureBarChart() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + reloadAllFields() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + clearChartMarkers() + } +} + + +// MARK: - Public Interface +// +extension PeriodDataViewController { + func clearAllFields() { + barChartView?.clear() + orderStats = nil + siteStats = nil + reloadAllFields() + } +} + + +// MARK: - User Interface Configuration +// +private extension PeriodDataViewController { + + func configureView() { + view.backgroundColor = StyleManager.wooWhite + borderView.backgroundColor = StyleManager.wooGreyBorder + + // Titles + visitorsTitle.applyFootnoteStyle() + ordersTitle.applyFootnoteStyle() + revenueTitle.applyFootnoteStyle() + + // Data + visitorsData.applyTitleStyle() + ordersData.applyTitleStyle() + revenueData.applyTitleStyle() + + // Footer + lastUpdated.font = UIFont.footnote + lastUpdated.textColor = StyleManager.wooGreyMid + } + + func configureBarChart() { + barChartView.chartDescription?.enabled = false + barChartView.dragEnabled = false + barChartView.setScaleEnabled(false) + barChartView.pinchZoomEnabled = false + barChartView.rightAxis.enabled = false + barChartView.legend.enabled = false + barChartView.drawValueAboveBarEnabled = true + barChartView.noDataText = NSLocalizedString("No data available", comment: "Text displayed when no data is available for revenue chart.") + barChartView.noDataFont = StyleManager.chartLabelFont + barChartView.noDataTextColor = StyleManager.wooSecondary + barChartView.extraRightOffset = Constants.chartExtraRightOffset + barChartView.delegate = self + + let xAxis = barChartView.xAxis + xAxis.labelPosition = .bottom + xAxis.setLabelCount(2, force: true) + xAxis.labelFont = StyleManager.chartLabelFont + xAxis.labelTextColor = StyleManager.wooSecondary + xAxis.axisLineColor = StyleManager.wooGreyBorder + xAxis.gridColor = StyleManager.wooGreyBorder + xAxis.drawLabelsEnabled = true + xAxis.drawGridLinesEnabled = false + xAxis.drawAxisLineEnabled = false + xAxis.granularity = Constants.chartXAxisGranularity + xAxis.granularityEnabled = true + xAxis.valueFormatter = self + + let yAxis = barChartView.leftAxis + yAxis.labelFont = StyleManager.chartLabelFont + yAxis.labelTextColor = StyleManager.wooSecondary + yAxis.axisLineColor = StyleManager.wooGreyBorder + yAxis.gridColor = StyleManager.wooGreyBorder + yAxis.gridLineDashLengths = Constants.chartXAxisDashLengths + yAxis.axisLineDashPhase = Constants.chartXAxisDashPhase + yAxis.zeroLineColor = StyleManager.wooGreyBorder + yAxis.drawLabelsEnabled = true + yAxis.drawGridLinesEnabled = true + yAxis.drawAxisLineEnabled = false + yAxis.drawZeroLineEnabled = true + yAxis.axisMinimum = Constants.chartYAxisMinimum + yAxis.valueFormatter = self + } +} + + +// MARK: - IndicatorInfoProvider Conformance (Tab Bar) +// +extension PeriodDataViewController { + func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { + return IndicatorInfo(title: granularity.pluralizedString) + } +} + + +// MARK: - ChartViewDelegate Conformance (Charts) +// +extension PeriodDataViewController: ChartViewDelegate { + func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) { + guard entry.y != 0.0 else { + // Do not display the marker if the Y-value is zero + clearChartMarkers() + return + } + + let marker = ChartMarker(chartView: chartView, + color: StyleManager.wooSecondary, + font: StyleManager.chartLabelFont, + textColor: StyleManager.wooWhite, + insets: Constants.chartMarkerInsets) + marker.minimumSize = Constants.chartMarkerMinimumSize + marker.arrowSize = Constants.chartMarkerArrowSize + chartView.marker = marker + } +} + + +// MARK: - IAxisValueFormatter Conformance (Charts) +// +extension PeriodDataViewController: IAxisValueFormatter { + func stringForValue(_ value: Double, axis: AxisBase?) -> String { + guard let axis = axis, let orderStats = orderStats else { + return "" + } + + if axis is XAxis { + if let item = orderStats.items?[Int(value)] { + var dateString = "" + switch orderStats.granularity { + case .day: + if let periodDate = DateFormatter.Stats.statsDayFormatter.date(from: item.period) { + dateString = DateFormatter.Charts.chartsDayFormatter.string(from: periodDate) + } + case .week: + if let periodDate = DateFormatter.Stats.statsWeekFormatter.date(from: item.period) { + dateString = DateFormatter.Charts.chartsWeekFormatter.string(from: periodDate) + } + case .month: + if let periodDate = DateFormatter.Stats.statsMonthFormatter.date(from: item.period) { + dateString = DateFormatter.Charts.chartsMonthFormatter.string(from: periodDate) + } + case .year: + if let periodDate = DateFormatter.Stats.statsYearFormatter.date(from: item.period) { + dateString = DateFormatter.Charts.chartsYearFormatter.string(from: periodDate) + } + } + + return dateString + } else { + return "" + } + } else { + if value == 0.0 { + // Do not show the "0" label on the Y axis + return "" + } else { + return value.friendlyString() + } + } + } +} + + +// MARK: - Private Helpers +// +private extension PeriodDataViewController { + + func reloadAllFields() { + reloadOrderFields() + reloadSiteFields() + reloadChart() + reloadLastUpdatedField() + } + + func reloadOrderFields() { + guard ordersData != nil, revenueData != nil else { + return + } + + var totalOrdersText = Constants.placeholderText + var totalRevenueText = Constants.placeholderText + if let orderStats = orderStats { + totalOrdersText = Double(orderStats.totalOrders).friendlyString() + let currencySymbol = orderStats.currencySymbol + let totalRevenue = orderStats.totalSales.friendlyString() + totalRevenueText = "\(currencySymbol)\(totalRevenue)" + } + ordersData.text = totalOrdersText + revenueData.text = totalRevenueText + } + + func reloadSiteFields() { + guard visitorsData != nil else { + return + } + + var visitorsText = Constants.placeholderText + if let siteStats = siteStats { + visitorsText = Double(siteStats.totalVisitors).friendlyString() + } + visitorsData.text = visitorsText + } + + func reloadChart() { + guard barChartView != nil else { + return + } + barChartView.data = generateBarDataSet() + barChartView.fitBars = true + barChartView.notifyDataSetChanged() + barChartView.animate(yAxisDuration: Constants.chartAnimationDuration) + } + + func reloadLastUpdatedField() { + if lastUpdated != nil { lastUpdated.text = summaryDateUpdated } + } + + func clearChartMarkers() { + barChartView.highlightValue(nil, callDelegate: false) + } + + func generateBarDataSet() -> BarChartData? { + guard let orderStats = orderStats, let statItems = orderStats.items, !statItems.isEmpty else { + return nil + } + + var barCount = 0 + var dataEntries: [BarChartDataEntry] = [] + statItems.forEach { (item) in + let entry = BarChartDataEntry(x: Double(barCount), y: item.totalSales) + entry.accessibilityValue = "\(item.period): \(orderStats.currencySymbol)\(item.totalSales.friendlyString())" + dataEntries.append(entry) + barCount += 1 + } + + let dataSet = BarChartDataSet(values: dataEntries, label: "Data") + dataSet.setColor(StyleManager.wooCommerceBrandColor) + dataSet.highlightEnabled = true + dataSet.highlightColor = StyleManager.wooAccent + dataSet.highlightAlpha = Constants.chartHighlightAlpha + dataSet.drawValuesEnabled = false // Do not draw value labels on the top of the bars + return BarChartData(dataSet: dataSet) + } +} + + +// MARK: - Constants! +// +private extension PeriodDataViewController { + enum Constants { + static let placeholderText = "-" + + static let chartAnimationDuration: TimeInterval = 0.75 + static let chartExtraRightOffset: CGFloat = 25.0 + static let chartHighlightAlpha: CGFloat = 1.0 + + static let chartMarkerInsets: UIEdgeInsets = UIEdgeInsets(top: 5.0, left: 2.0, bottom: 5.0, right: 2.0) + static let chartMarkerMinimumSize: CGSize = CGSize(width: 50.0, height: 30.0) + static let chartMarkerArrowSize: CGSize = CGSize(width: 8, height: 6) + + static let chartXAxisDashLengths: [CGFloat] = [5.0, 5.0] + static let chartXAxisDashPhase: CGFloat = 0.0 + static let chartXAxisGranularity: Double = 1.0 + static let chartYAxisMinimum: Double = 0.0 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.xib b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.xib new file mode 100644 index 00000000000..2671ee98a09 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.xib @@ -0,0 +1,212 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/StoreStatsViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/StoreStatsViewController.swift new file mode 100644 index 00000000000..33323d4bbad --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/StoreStatsViewController.swift @@ -0,0 +1,190 @@ +import UIKit +import Yosemite +import CocoaLumberjack +import XLPagerTabStrip + + +// MARK: - MyStoreStatsViewController +// +class StoreStatsViewController: ButtonBarPagerTabStripViewController { + + // MARK: - Properties + + @IBOutlet private weak var topBorder: UIView! + @IBOutlet private weak var middleBorder: UIView! + @IBOutlet private weak var bottomBorder: UIView! + + private var periodVCs = [PeriodDataViewController]() + + // MARK: - View Lifecycle + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func viewDidLoad() { + configurePeriodViewControllers() + configureTabStrip() + // 👆 must be called before super.viewDidLoad() + + super.viewDidLoad() + configureView() + } + + // MARK: - PagerTabStripDataSource + + override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] { + return periodVCs + } +} + + +// MARK: - Public Interface +// +extension StoreStatsViewController { + func syncAllStats() { + periodVCs.forEach { (vc) in + vc.clearAllFields() + syncOrderStats(for: vc.granularity) + syncVisitorStats(for: vc.granularity) + } + } +} + + +// MARK: - User Interface Configuration +// +private extension StoreStatsViewController { + + func configureView() { + view.backgroundColor = StyleManager.tableViewBackgroundColor + topBorder.backgroundColor = StyleManager.wooGreyBorder + middleBorder.backgroundColor = StyleManager.wooGreyBorder + bottomBorder.backgroundColor = StyleManager.wooGreyBorder + } + + func configurePeriodViewControllers() { + let dayVC = PeriodDataViewController(granularity: .day) + let weekVC = PeriodDataViewController(granularity: .week) + let monthVC = PeriodDataViewController(granularity: .month) + let yearVC = PeriodDataViewController(granularity: .year) + + periodVCs.append(dayVC) + periodVCs.append(weekVC) + periodVCs.append(monthVC) + periodVCs.append(yearVC) + } + + func configureTabStrip() { + settings.style.buttonBarBackgroundColor = StyleManager.wooWhite + settings.style.buttonBarItemBackgroundColor = StyleManager.wooWhite + settings.style.selectedBarBackgroundColor = StyleManager.wooCommerceBrandColor + settings.style.buttonBarItemFont = StyleManager.subheadlineFont + settings.style.selectedBarHeight = TabStrip.selectedBarHeight + settings.style.buttonBarItemTitleColor = StyleManager.defaultTextColor + settings.style.buttonBarItemsShouldFillAvailableWidth = false + settings.style.buttonBarItemLeftRightMargin = TabStrip.buttonLeftRightMargin + + changeCurrentIndexProgressive = { (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, progressPercentage: CGFloat, changeCurrentIndex: Bool, animated: Bool) -> Void in + guard changeCurrentIndex == true else { return } + oldCell?.label.textColor = StyleManager.defaultTextColor + newCell?.label.textColor = StyleManager.wooCommerceBrandColor + } + } +} + + +// MARK: - Sync'ing Helpers +// +private extension StoreStatsViewController { + + func syncVisitorStats(for granularity: StatGranularity, onCompletion: ((Error?) -> ())? = nil) { + // FIXME: This is really just WIP code which puts data in the fields. Refactor please. + guard let siteID = StoresManager.shared.sessionManager.defaultStoreID else { + onCompletion?(nil) + return + } + + let action = StatsAction.retrieveSiteVisitStats(siteID: siteID, + granularity: granularity, + latestDateToInclude: Date(), + quantity: quantity(for: granularity)) { [weak self] (siteVisitStats, error) in + guard let `self` = self, let siteVisitStats = siteVisitStats else { + DDLogError("⛔️ Error synchronizing site visit stats: \(error.debugDescription)") + onCompletion?(error) + return + } + + let vc = self.periodDataVC(for: granularity) + vc?.siteStats = siteVisitStats + onCompletion?(nil) + } + + StoresManager.shared.dispatch(action) + } + + func syncOrderStats(for granularity: StatGranularity, onCompletion: ((Error?) -> ())? = nil) { + // FIXME: This is really just WIP code which puts data in the fields. Refactor please. + guard let siteID = StoresManager.shared.sessionManager.defaultStoreID else { + onCompletion?(nil) + return + } + + let action = StatsAction.retrieveOrderStats(siteID: siteID, + granularity: granularity, + latestDateToInclude: Date(), + quantity: quantity(for: granularity)) { [weak self] (orderStats, error) in + guard let `self` = self, let orderStats = orderStats else { + DDLogError("⛔️ Error synchronizing order stats: \(error.debugDescription)") + onCompletion?(error) + return + } + + let vc = self.periodDataVC(for: granularity) + vc?.orderStats = orderStats + onCompletion?(nil) + } + + StoresManager.shared.dispatch(action) + } +} + + +// MARK: - Private Helpers +// +private extension StoreStatsViewController { + + func periodDataVC(for granularity: StatGranularity) -> PeriodDataViewController? { + return periodVCs.filter({ $0.granularity == granularity }).first + } + + func quantity(for granularity: StatGranularity) -> Int { + switch granularity { + case .day: + return Constants.quantityDefaultForDay + case .week: + return Constants.quantityDefaultForWeek + case .month: + return Constants.quantityDefaultForMonth + case .year: + return Constants.quantityDefaultForYear + } + } +} + + +// MARK: - Constants! +// +private extension StoreStatsViewController { + enum Constants { + static let quantityDefaultForDay = 30 + static let quantityDefaultForWeek = 13 + static let quantityDefaultForMonth = 12 + static let quantityDefaultForYear = 5 + } + + enum TabStrip { + static let buttonLeftRightMargin: CGFloat = 14.0 + static let selectedBarHeight: CGFloat = 3.0 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/SettingsViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/SettingsViewController.swift index 0adc1f066c1..72e412e1ae0 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/SettingsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/SettingsViewController.swift @@ -43,6 +43,33 @@ class SettingsViewController: UIViewController { } } } + + func handleLogout() { + var accountName: String = "" + if let account = StoresManager.shared.sessionManager.defaultAccount { + accountName = account.displayName + } + + let name = String(format: NSLocalizedString("Are you sure you want to log out of the account %@?", comment: "Alert message to confirm a user meant to log out."), accountName) + let alertController = UIAlertController(title: "", message: name, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Back", comment: "Alert button title - dismisses alert, which cancels the log out attempt"), style: .cancel) + alertController.addAction(cancelAction) + + let logOutAction = UIAlertAction(title: NSLocalizedString("Log Out", comment: "Alert button title - confirms and logs out the user"), style: .default) { (action) in + self.logOutUser() + } + alertController.addAction(logOutAction) + + alertController.preferredAction = logOutAction + present(alertController, animated: true) + } + + func logOutUser() { + WooAnalytics.shared.track(.logout) + StoresManager.shared.deauthenticate() + navigationController?.popToRootViewController(animated: true) + } } // MARK: - UITableViewDataSource Conformance @@ -73,14 +100,12 @@ extension SettingsViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let row = sections[indexPath.section].rows[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) - if cell is LogOutTableViewCell { - let logoutCell = cell as! LogOutTableViewCell + if let logoutCell = cell as? LogOutTableViewCell { logoutCell.didSelectLogout = { [weak self] in - WooAnalytics.shared.track(.logout) - StoresManager.shared.deauthenticate() - self?.navigationController?.popToRootViewController(animated: true) + self?.handleLogout() } } + return cell } diff --git a/WooCommerce/Classes/ViewRelated/NotificationsViewController.swift b/WooCommerce/Classes/ViewRelated/NotificationsViewController.swift index d6d7f4e69b9..bbb7648b15a 100644 --- a/WooCommerce/Classes/ViewRelated/NotificationsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/NotificationsViewController.swift @@ -7,5 +7,6 @@ class NotificationsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = StyleManager.tableViewBackgroundColor } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/FulfillViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/FulfillViewController.swift index 31a0b94489f..b9f5f9ec125 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/FulfillViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/FulfillViewController.swift @@ -76,6 +76,7 @@ private extension FulfillViewController { /// func setupMainView() { view.backgroundColor = StyleManager.tableViewBackgroundColor + tableView.backgroundColor = StyleManager.tableViewBackgroundColor } /// Setup: TableView @@ -252,12 +253,16 @@ private extension FulfillViewController { /// Setup: Address Cell /// - private func setupAddressCell(_ cell: UITableViewCell, with address: Address) { + private func setupAddressCell(_ cell: UITableViewCell, with address: Address?) { guard let cell = cell as? CustomerInfoTableViewCell else { fatalError() } - let address = order.shippingAddress + guard let address = order.shippingAddress ?? order.billingAddress else { + cell.title = NSLocalizedString("Shipping details", comment: "Shipping title for customer info cell") + cell.address = NSLocalizedString("No address specified.", comment: "Fulfill order > customer info > where the address would normally display.") + return + } cell.title = NSLocalizedString("Shipping details", comment: "Shipping title for customer info cell") cell.name = address.fullName @@ -301,7 +306,7 @@ private enum Row { /// Represents an Address Row /// - case address(shipping: Address) + case address(shipping: Address?) /// Represents an "Add Tracking" Row /// @@ -377,7 +382,13 @@ private extension Section { let address: Section = { let title = NSLocalizedString("Customer Information", comment: "") - let row = Row.address(shipping: order.shippingAddress) + if let shippingAddress = order.shippingAddress { + let row = Row.address(shipping: shippingAddress) + + return Section(title: title, secondaryTitle: nil, rows: [row]) + } + + let row = Row.address(shipping: order.billingAddress) return Section(title: title, secondaryTitle: nil, rows: [row]) }() diff --git a/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/AddNote/AddANoteViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/AddNote/AddANoteViewController.swift index 773e052f714..334ec56e102 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/AddNote/AddANoteViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/AddNote/AddANoteViewController.swift @@ -82,6 +82,8 @@ private extension AddANoteViewController { /// Setup: TableView /// private func configureTableView() { + view.backgroundColor = StyleManager.tableViewBackgroundColor + tableView.backgroundColor = StyleManager.tableViewBackgroundColor tableView.estimatedRowHeight = Constants.rowHeight tableView.rowHeight = UITableViewAutomaticDimension } diff --git a/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/BillingDetailsTableViewCell.xib b/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/BillingDetailsTableViewCell.xib index e36748e1ff9..26f1d659566 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/BillingDetailsTableViewCell.xib +++ b/WooCommerce/Classes/ViewRelated/Orders/OrderDetails/BillingDetailsTableViewCell.xib @@ -18,7 +18,7 @@ -