diff --git a/Podfile b/Podfile index cb023b20d87..a85372a11d3 100644 --- a/Podfile +++ b/Podfile @@ -30,6 +30,7 @@ target 'WooCommerce' do 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 e2aa4fdd935..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) @@ -69,6 +72,7 @@ PODS: 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) @@ -81,6 +85,7 @@ SPEC REPOS: https://github.com/cocoapods/specs.git: - 1PasswordExtension - Alamofire + - Charts - CocoaLumberjack - Crashlytics - Fabric @@ -116,6 +121,7 @@ SPEC CHECKSUMS: 1PasswordExtension: 0e95bdea64ec8ff2f4f693be5467a09fac42a83d Alamofire: e4fa87002c137ba2d8d634d2c51fabcda0d5c223 Automattic-Tracks-iOS: d8c6c6c1351b1905a73e45f431b15598d71963b5 + Charts: 90a4d61da0f6e06684c591e3bcab11940fe61736 CocoaLumberjack: db7cc9e464771f12054c22ff6947c5a58d43a0fd Crashlytics: ccaac42660eb9351b9960c0d66106b0bcf99f4fa Fabric: f233c9492b3bbc1f04e3882986740f7988a58edb @@ -137,6 +143,6 @@ SPEC CHECKSUMS: wpxmlrpc: bfc572f62ce7ee897f6f38b098d2ba08732ecef4 XLPagerTabStrip: c908b17cbf42fcd2598ee1adfc49bae25444d88a -PODFILE CHECKSUM: f61f936dde41c2e27f9fe80a0da46c2def730421 +PODFILE CHECKSUM: 579bb6345aecc3f27e78d220e137a749e9ee5f08 COCOAPODS: 1.5.3 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/Model/OrderStats+Woo.swift b/WooCommerce/Classes/Model/OrderStats+Woo.swift index c12ed998fef..bfcaad462a9 100644 --- a/WooCommerce/Classes/Model/OrderStats+Woo.swift +++ b/WooCommerce/Classes/Model/OrderStats+Woo.swift @@ -18,4 +18,13 @@ extension OrderStats { 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/Styles/Style.swift b/WooCommerce/Classes/Styles/Style.swift index 06f652ce98a..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 } @@ -51,6 +52,7 @@ class DefaultStyle: Style { 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! // @@ -182,6 +184,10 @@ class StyleManager { return active.cellSeparatorColor } + static var chartLabelFont: UIFont { + return active.chartLabelFont + } + static var defaultTextColor: UIColor { return active.defaultTextColor } 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 index 20727a06934..3f95a4f8e91 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.swift @@ -1,7 +1,7 @@ import UIKit import Yosemite +import Charts import XLPagerTabStrip -import WordPressShared import CocoaLumberjack @@ -15,8 +15,8 @@ class PeriodDataViewController: UIViewController, IndicatorInfoProvider { @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 chartView: UIView! @IBOutlet private weak var borderView: UIView! private var lastUpdatedDate: Date? @@ -25,6 +25,7 @@ class PeriodDataViewController: UIViewController, IndicatorInfoProvider { didSet { lastUpdatedDate = Date() reloadOrderFields() + reloadChart() reloadLastUpdatedField() } } @@ -67,12 +68,18 @@ class PeriodDataViewController: UIViewController, IndicatorInfoProvider { 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() + } } @@ -80,6 +87,7 @@ class PeriodDataViewController: UIViewController, IndicatorInfoProvider { // extension PeriodDataViewController { func clearAllFields() { + barChartView?.clear() orderStats = nil siteStats = nil reloadAllFields() @@ -109,10 +117,54 @@ private extension PeriodDataViewController { 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 Confromance +// MARK: - IndicatorInfoProvider Conformance (Tab Bar) // extension PeriodDataViewController { func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { @@ -121,6 +173,74 @@ extension PeriodDataViewController { } +// 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 { @@ -128,6 +248,7 @@ private extension PeriodDataViewController { func reloadAllFields() { reloadOrderFields() reloadSiteFields() + reloadChart() reloadLastUpdatedField() } @@ -141,7 +262,7 @@ private extension PeriodDataViewController { if let orderStats = orderStats { totalOrdersText = Double(orderStats.totalOrders).friendlyString() let currencySymbol = orderStats.currencySymbol - let totalRevenue = orderStats.totalGrossSales.friendlyString() + let totalRevenue = orderStats.totalSales.friendlyString() totalRevenueText = "\(currencySymbol)\(totalRevenue)" } ordersData.text = totalOrdersText @@ -160,9 +281,46 @@ private extension PeriodDataViewController { 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) + } } @@ -170,6 +328,19 @@ private extension PeriodDataViewController { // private extension PeriodDataViewController { enum Constants { - static let placeholderText = "-" + 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 index 4a599aa0925..2671ee98a09 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.xib +++ b/WooCommerce/Classes/ViewRelated/Dashboard/MyStore/PeriodDataViewController.xib @@ -11,8 +11,8 @@ + - @@ -158,13 +158,32 @@ - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + +