Skip to content

Commit 5323ed6

Browse files
authored
Merge pull request #7708 from woocommerce/issue/7565-store-formatting
Widgets: Data formatting
2 parents 67b8432 + 22e4fe2 commit 5323ed6

File tree

6 files changed

+80
-15
lines changed

6 files changed

+80
-15
lines changed

WooCommerce/Classes/Extensions/UserDefaults+Woo.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension UserDefaults {
1111
case defaultSiteAddress
1212
case defaultStoreID
1313
case defaultStoreName
14+
case defaultStoreCurrencySettings
1415
case defaultAnonymousID
1516
case defaultRoles
1617
case deviceID

WooCommerce/Classes/Tools/Shared Site Settings/SelectedSiteSettings.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ extension SelectedSiteSettings {
6262
fetchedObjects.forEach {
6363
ServiceLocator.currencySettings.updateCurrencyOptions(with: $0)
6464
}
65+
66+
UserDefaults.group?[.defaultStoreCurrencySettings] = try? JSONEncoder().encode(ServiceLocator.currencySettings)
6567
}
6668
}
6769

WooCommerce/StoreWidgets/StoreInfoDataService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ final class StoreInfoDataService {
4242
let (revenueAndOrders, visitors) = try await (revenueAndOrdersRequest, visitorsRequest)
4343

4444
// Assemble stats data
45-
let conversion = visitors.totalVisitors > 0 ? Double(revenueAndOrders.totals.totalOrders) / Double(visitors.totalVisitors) * 100 : 0
45+
let conversion = visitors.totalVisitors > 0 ? Double(revenueAndOrders.totals.totalOrders) / Double(visitors.totalVisitors) : 0
4646
return Stats(revenue: revenueAndOrders.totals.grossRevenue,
4747
totalOrders: revenueAndOrders.totals.totalOrders,
4848
totalVisitors: visitors.totalVisitors,
49-
conversion: conversion)
49+
conversion: min(conversion, 1))
5050
}
5151
}
5252

WooCommerce/StoreWidgets/StoreInfoProvider.swift

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import WidgetKit
2+
import WooFoundation
23
import KeychainAccess
34

45
/// Type that represents the all the possible Widget states.
@@ -53,16 +54,20 @@ final class StoreInfoProvider: TimelineProvider {
5354
///
5455
private var networkService: StoreInfoDataService?
5556

57+
/// Desired data reload interval provided to system = 30 minutes.
58+
///
59+
private let reloadInterval: TimeInterval = 30 * 60
60+
5661
/// Redacted entry with sample data.
5762
///
5863
func placeholder(in context: Context) -> StoreInfoEntry {
5964
let dependencies = Self.fetchDependencies()
6065
return StoreInfoEntry.data(.init(range: Localization.today,
6166
name: dependencies?.storeName ?? Localization.myShop,
62-
revenue: "$132.234",
67+
revenue: Self.formattedAmountString(for: 132.234, with: dependencies?.storeCurrencySettings),
6368
visitors: "67",
6469
orders: "23",
65-
conversion: "34%"))
70+
conversion: Self.formattedConversionString(for: 23/67)))
6671
}
6772

6873
/// Quick Snapshot. Required when previewing the widget.
@@ -72,7 +77,6 @@ final class StoreInfoProvider: TimelineProvider {
7277
}
7378

7479
/// Real data widget.
75-
/// TODO: Update with real data.
7680
///
7781
func getTimeline(in context: Context, completion: @escaping (Timeline<StoreInfoEntry>) -> Void) {
7882
guard let dependencies = Self.fetchDependencies() else {
@@ -85,15 +89,14 @@ final class StoreInfoProvider: TimelineProvider {
8589
do {
8690
let todayStats = try await strongService.fetchTodayStats(for: dependencies.storeID)
8791

88-
// TODO: Use proper store formatting.
8992
let entry = StoreInfoEntry.data(.init(range: Localization.today,
9093
name: dependencies.storeName,
91-
revenue: "$\(todayStats.revenue)",
94+
revenue: Self.formattedAmountString(for: todayStats.revenue, with: dependencies.storeCurrencySettings),
9295
visitors: "\(todayStats.totalVisitors)",
9396
orders: "\(todayStats.totalOrders)",
94-
conversion: "\(todayStats.conversion)%"))
97+
conversion: Self.formattedConversionString(for: todayStats.conversion)))
9598

96-
let reloadDate = Date(timeIntervalSinceNow: 30 * 60) // Ask for a 15 minutes reload.
99+
let reloadDate = Date(timeIntervalSinceNow: reloadInterval)
97100
let timeline = Timeline<StoreInfoEntry>(entries: [entry], policy: .after(reloadDate))
98101
completion(timeline)
99102

@@ -102,7 +105,7 @@ final class StoreInfoProvider: TimelineProvider {
102105
// WooFoundation does not expose `DDLOG` types. Should we include them?
103106
print("⛔️ Error fetching today's widget stats: \(error)")
104107

105-
let reloadDate = Date(timeIntervalSinceNow: 30 * 60) // Ask for a 30 minutes reload.
108+
let reloadDate = Date(timeIntervalSinceNow: reloadInterval)
106109
let timeline = Timeline<StoreInfoEntry>(entries: [.error], policy: .after(reloadDate))
107110
completion(timeline)
108111
}
@@ -118,6 +121,7 @@ private extension StoreInfoProvider {
118121
let authToken: String
119122
let storeID: Int64
120123
let storeName: String
124+
let storeCurrencySettings: CurrencySettings
121125
}
122126

123127
/// Fetches the required dependencies from the keychain and the shared users default.
@@ -126,14 +130,40 @@ private extension StoreInfoProvider {
126130
let keychain = Keychain(service: WooConstants.keychainServiceName)
127131
guard let authToken = keychain[WooConstants.authToken],
128132
let storeID = UserDefaults.group?[.defaultStoreID] as? Int64,
129-
let storeName = UserDefaults.group?[.defaultStoreName] as? String else {
133+
let storeName = UserDefaults.group?[.defaultStoreName] as? String,
134+
let storeCurrencySettingsData = UserDefaults.group?[.defaultStoreCurrencySettings] as? Data,
135+
let storeCurrencySettings = try? JSONDecoder().decode(CurrencySettings.self, from: storeCurrencySettingsData) else {
130136
return nil
131137
}
132-
return Dependencies(authToken: authToken, storeID: storeID, storeName: storeName)
138+
return Dependencies(authToken: authToken,
139+
storeID: storeID,
140+
storeName: storeName,
141+
storeCurrencySettings: storeCurrencySettings)
133142
}
134143
}
135144

136145
private extension StoreInfoProvider {
146+
147+
static func formattedAmountString(for amountValue: Decimal, with currencySettings: CurrencySettings?) -> String {
148+
let currencyFormatter = CurrencyFormatter(currencySettings: currencySettings ?? CurrencySettings())
149+
return currencyFormatter.formatAmount(amountValue) ?? Constants.valuePlaceholderText
150+
}
151+
152+
static func formattedConversionString(for conversionRate: Double) -> String {
153+
let numberFormatter = NumberFormatter()
154+
numberFormatter.numberStyle = .percent
155+
numberFormatter.minimumFractionDigits = 1
156+
157+
// do not add 0 fraction digit if the percentage is round
158+
let minimumFractionDigits = floor(conversionRate * 100.0) == conversionRate * 100.0 ? 0 : 1
159+
numberFormatter.minimumFractionDigits = minimumFractionDigits
160+
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.valuePlaceholderText
161+
}
162+
163+
enum Constants {
164+
static let valuePlaceholderText = "-"
165+
}
166+
137167
enum Localization {
138168
static let myShop = AppLocalizedString(
139169
"storeWidgets.infoProvider.myShop",

WooFoundation/WooFoundation/Currency/CurrencyCode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// The 3-letter country code for supported currencies
22
///
3-
public enum CurrencyCode: String, CaseIterable {
3+
public enum CurrencyCode: String, CaseIterable, Codable {
44
// A
55
case AED, AFN, ALL, AMD, ANG, AOA, ARS, AUD, AWG, AZN,
66
// B

WooFoundation/WooFoundation/Currency/CurrencySettings.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import Foundation
22

33
/// Site-wide settings for displaying prices/money
44
///
5-
public class CurrencySettings {
5+
public class CurrencySettings: Codable {
66

77
// MARK: - Enums
88

99
/// Designates where the currency symbol is located on a formatted price
1010
///
11-
public enum CurrencyPosition: String {
11+
public enum CurrencyPosition: String, Codable {
1212
case left = "left"
1313
case right = "right"
1414
case leftSpace = "left_space"
@@ -394,4 +394,36 @@ public class CurrencySettings {
394394
return "ZK"
395395
}
396396
}
397+
398+
// MARK: - Codable implementation
399+
// Used for serialization in UserDefaults to share settings between app and widgets extension
400+
//
401+
// No custom logic, but it is required because `@Published` property prevents automatic Codable synthesis
402+
// (currencyCode type is Published<CurrencyCode> instead of CurrencyCode)
403+
404+
enum CodingKeys: CodingKey {
405+
case currencyCode
406+
case currencyPosition
407+
case groupingSeparator
408+
case decimalSeparator
409+
case fractionDigits
410+
}
411+
412+
public required init(from decoder: Decoder) throws {
413+
let container = try decoder.container(keyedBy: CodingKeys.self)
414+
currencyCode = try container.decode(CurrencyCode.self, forKey: .currencyCode)
415+
currencyPosition = try container.decode(CurrencyPosition.self, forKey: .currencyPosition)
416+
groupingSeparator = try container.decode(String.self, forKey: .groupingSeparator)
417+
decimalSeparator = try container.decode(String.self, forKey: .decimalSeparator)
418+
fractionDigits = try container.decode(Int.self, forKey: .fractionDigits)
419+
}
420+
421+
public func encode(to encoder: Encoder) throws {
422+
var container = encoder.container(keyedBy: CodingKeys.self)
423+
try container.encode(currencyCode, forKey: .currencyCode)
424+
try container.encode(currencyPosition, forKey: .currencyPosition)
425+
try container.encode(groupingSeparator, forKey: .groupingSeparator)
426+
try container.encode(decimalSeparator, forKey: .decimalSeparator)
427+
try container.encode(fractionDigits, forKey: .fractionDigits)
428+
}
397429
}

0 commit comments

Comments
 (0)