| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- //
- // QuantityFormatter.swift
- // LoopKit
- //
- // Copyright © 2018 LoopKit Authors. All rights reserved.
- //
- import Foundation
- import HealthKit
- /// Formats unit quantities as localized strings
- open class QuantityFormatter {
- public init() {
- }
- public convenience init(for unit: HKUnit) {
- self.init()
- setPreferredNumberFormatter(for: unit)
- }
- /// The unit style determines how the unit strings are abbreviated, and spacing between the value and unit
- open var unitStyle: Formatter.UnitStyle = .medium {
- didSet {
- if hasMeasurementFormatter {
- measurementFormatter.unitStyle = unitStyle
- }
- if hasMassFormatter {
- massFormatter.unitStyle = unitStyle
- }
- }
- }
- open var locale: Locale = Locale.current {
- didSet {
- if hasNumberFormatter {
- numberFormatter.locale = locale
- }
- if hasMeasurementFormatter {
- measurementFormatter.locale = locale
- }
- }
- }
- /// Updates `numberFormatter` configuration for the specified unit
- ///
- /// - Parameter unit: The unit
- open func setPreferredNumberFormatter(for unit: HKUnit) {
- numberFormatter.numberStyle = .decimal
- numberFormatter.minimumFractionDigits = unit.preferredFractionDigits
- numberFormatter.maximumFractionDigits = unit.maxFractionDigits
- }
- private var hasNumberFormatter = false
- /// The formatter used for the quantity values
- open private(set) lazy var numberFormatter: NumberFormatter = {
- hasNumberFormatter = true
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.locale = self.locale
- return formatter
- }()
- private var hasMeasurementFormatter = false
- /// MeasurementFormatter is used for gram measurements, mg/dL units, and mmol/L units.
- /// It does not properly handle glucose measurements, as it changes unit scales: 100 mg/dL -> 1 g/L
- private lazy var measurementFormatter: MeasurementFormatter = {
- hasMeasurementFormatter = true
- let formatter = MeasurementFormatter()
- formatter.unitOptions = [.providedUnit]
- formatter.numberFormatter = self.numberFormatter
- formatter.locale = self.locale
- formatter.unitStyle = self.unitStyle
- return formatter
- }()
- private var hasMassFormatter = false
- /// MassFormatter properly creates unit strings for grams in .short/.medium style as "g", where MeasurementFormatter uses "gram"/"grams"
- private lazy var massFormatter: MassFormatter = {
- hasMassFormatter = true
- let formatter = MassFormatter()
- formatter.numberFormatter = self.numberFormatter
- formatter.unitStyle = self.unitStyle
- return formatter
- }()
- /// When `avoidLineBreaking` is true, the formatter avoids unit strings or values and their unit strings being split by a line break.
- open var avoidLineBreaking: Bool = true
-
- /// Formats a quantity and unit as a localized string
- ///
- /// - Parameters:
- /// - quantity: The quantity
- /// - unit: The unit. An exception is thrown if `quantity` is not compatible with the unit.
- /// - includeUnit: Whether or not to include the unit in the returned string
- /// - Returns: A localized string, or nil if `numberFormatter` is unable to format the quantity value
- open func string(from quantity: HKQuantity, for unit: HKUnit, includeUnit: Bool = true) -> String? {
- let value = quantity.doubleValue(for: unit)
- if !includeUnit {
- return numberFormatter.string(from: value)
- }
- if let foundationUnit = unit.foundationUnit, unit.usesMeasurementFormatterForMeasurement {
- return measurementFormatter.string(from: Measurement(value: value, unit: foundationUnit)).avoidLineBreaking(enabled: avoidLineBreaking)
- }
-
- // Pass 'false' for `avoidLineBreaking` because we don't want to do it twice.
- return numberFormatter.string(from: value, unit: string(from: unit, forValue: value, avoidLineBreaking: false),
- style: unitStyle, avoidLineBreaking: avoidLineBreaking)
- }
- /// Formats a unit as a localized string
- ///
- /// - Parameters:
- /// - unit: The unit
- /// - value: An optional value for determining the plurality of the unit string
- /// - Returns: A string for the unit. If no localization entry is available, the unlocalized `unitString` is returned.
- open func string(from unit: HKUnit, forValue value: Double = 10, avoidLineBreaking: Bool? = nil) -> String {
- let avoidLineBreaking = avoidLineBreaking ?? self.avoidLineBreaking
- if let string = unit.localizedUnitString(in: unitStyle, singular: abs(1.0 - value) < .ulpOfOne, avoidLineBreaking: avoidLineBreaking) {
- return string
- }
- let string: String
- if unit.usesMassFormatterForUnitString {
- string = massFormatter.unitString(fromValue: value, unit: HKUnit.massFormatterUnit(from: unit))
- } else if let foundationUnit = unit.foundationUnit {
- string = measurementFormatter.string(from: foundationUnit)
- } else {
- // Fallback, unlocalized
- string = unit.unitString
- }
- return string.avoidLineBreaking(enabled: avoidLineBreaking)
- }
- }
- public extension HKQuantity {
- func doubleValue(for unit: HKUnit, withRounding: Bool) -> Double {
- var value = self.doubleValue(for: unit)
- if withRounding {
- value = unit.round(value: value, fractionalDigits: unit.maxFractionDigits)
- }
- return value
- }
- }
- public extension HKUnit {
- var usesMassFormatterForUnitString: Bool {
- return self == .gram()
- }
- var usesMeasurementFormatterForMeasurement: Bool {
- return self == .gram()
- }
- var preferredFractionDigits: Int {
- switch self {
- case .millimolesPerLiter,
- HKUnit.millimolesPerLiter.unitDivided(by: .internationalUnit()),
- HKUnit.millimolesPerLiter.unitDivided(by: .minute()):
- return 1
- default:
- return 0
- }
- }
- var pickerFractionDigits: Int {
- switch self {
- case .internationalUnit(), .internationalUnitsPerHour:
- return 3
- case HKUnit.gram().unitDivided(by: .internationalUnit()):
- return 1
- case .millimolesPerLiter,
- HKUnit.millimolesPerLiter.unitDivided(by: .internationalUnit()),
- HKUnit.millimolesPerLiter.unitDivided(by: .minute()):
- return 1
- default:
- return 0
- }
- }
- func round(value: Double, fractionalDigits: Int) -> Double {
- if fractionalDigits == 0 {
- return value.rounded()
- } else {
- let scaleFactor = pow(10.0, Double(fractionalDigits))
- return (value * scaleFactor).rounded() / scaleFactor
- }
- }
- func round(value: Double) -> Double {
- return roundForPreferredDigits(value: value)
- }
- func roundForPreferredDigits(value: Double) -> Double {
- return round(value: value, fractionalDigits: preferredFractionDigits)
- }
- func roundForPicker(value: Double) -> Double {
- return round(value: value, fractionalDigits: pickerFractionDigits)
- }
- var maxFractionDigits: Int {
- switch self {
- case .internationalUnit(), .internationalUnitsPerHour:
- return 3
- case HKUnit.gram().unitDivided(by: .internationalUnit()):
- return 1
- default:
- return preferredFractionDigits
- }
- }
-
- // Short localized unit string with unlocalized fallback
- func shortLocalizedUnitString(avoidLineBreaking: Bool = true) -> String {
- return localizedUnitString(in: .short, avoidLineBreaking: avoidLineBreaking) ??
- unitString.avoidLineBreaking(enabled: avoidLineBreaking)
- }
- func localizedUnitString(in style: Formatter.UnitStyle, singular: Bool = false, avoidLineBreaking: Bool = true) -> String? {
-
- func localizedUnitStringInternal(in style: Formatter.UnitStyle, singular: Bool = false) -> String? {
- if self == .internationalUnit() {
- switch style {
- case .short, .medium:
- return LocalizedString("U", comment: "The short unit display string for international units of insulin")
- case .long:
- fallthrough
- @unknown default:
- if singular {
- return LocalizedString("Unit", comment: "The long unit display string for a singular international unit of insulin")
- } else {
- return LocalizedString("Units", comment: "The long unit display string for international units of insulin")
- }
- }
- }
- if self == .hour() {
- switch style {
- case .short, .medium:
- return unitString
- case .long:
- fallthrough
- @unknown default:
- if singular {
- return LocalizedString("Hour", comment: "The long unit display string for a singular hour")
- } else {
- return LocalizedString("Hours", comment: "The long unit display string for hours")
- }
- }
- }
-
- if self == .internationalUnitsPerHour {
- switch style {
- case .short, .medium:
- return LocalizedString("U/hr", comment: "The short unit display string for international units of insulin per hour")
- case .long:
- fallthrough
- @unknown default:
- if singular {
- return LocalizedString("Unit/hour", comment: "The long unit display string for a singular international unit of insulin per hour")
- } else {
- return LocalizedString("Units/hour", comment: "The long unit display string for international units of insulin per hour")
- }
- }
- }
-
- if self == HKUnit.millimolesPerLiter {
- switch style {
- case .short, .medium:
- return LocalizedString("mmol/L", comment: "The short unit display string for millimoles per liter")
- case .long:
- break // Fallback to the MeasurementFormatter localization
- @unknown default:
- break
- }
- }
-
- if self == HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()) {
- switch style {
- case .short, .medium:
- return LocalizedString("mg/dL/U", comment: "The short unit display string for milligrams per deciliter per U")
- case .long:
- break // Fallback to the MeasurementFormatter localization
- @unknown default:
- break
- }
- }
-
- if self == HKUnit.millimolesPerLiter.unitDivided(by: HKUnit.internationalUnit()) {
- switch style {
- case .short, .medium:
- return LocalizedString("mmol/L/U", comment: "The short unit display string for millimoles per liter per U")
- case .long:
- break // Fallback to the MeasurementFormatter localization
- @unknown default:
- break
- }
- }
-
- if self == HKUnit.gram().unitDivided(by: HKUnit.internationalUnit()) {
- switch style {
- case .short, .medium:
- return LocalizedString("g/U", comment: "The short unit display string for grams per U")
- case .long:
- fallthrough
- @unknown default:
- break // Fallback to the MeasurementFormatter localization
- }
- }
-
- return nil
- }
-
- if style != .long {
- return localizedUnitStringInternal(in: style, singular: singular)?.avoidLineBreaking(enabled: avoidLineBreaking)
- } else {
- return localizedUnitStringInternal(in: style, singular: singular)
- }
- }
- }
- fileprivate extension String {
- func avoidLineBreaking(around string: String = "/", enabled: Bool) -> String {
- guard enabled else {
- return self
- }
- return self.replacingOccurrences(of: string, with: "\(String.wordJoiner)\(string)\(String.wordJoiner)")
- }
- }
|