From f4e47f8796f561ed22b49b45f16e732bfd11a453 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Thu, 2 Sep 2021 14:19:43 +1000 Subject: [PATCH 1/6] add intro text options to price breakdown --- Afterpay.xcodeproj/project.pbxproj | 4 +++ .../Components/ComponentsViewController.swift | 1 + README.md | 34 +++++++++++++------ Sources/Afterpay/Model/PriceBreakdown.swift | 4 +-- Sources/Afterpay/Resources/IntroText.swift | 23 +++++++++++++ Sources/Afterpay/Resources/Strings.swift | 2 +- .../Afterpay/Views/PriceBreakdownView.swift | 8 ++++- 7 files changed, 62 insertions(+), 14 deletions(-) create mode 100644 Sources/Afterpay/Resources/IntroText.swift diff --git a/Afterpay.xcodeproj/project.pbxproj b/Afterpay.xcodeproj/project.pbxproj index 024607e4..571ba1e1 100644 --- a/Afterpay.xcodeproj/project.pbxproj +++ b/Afterpay.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 157E88D125CBCA49007E54C4 /* Result+Fold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157E88D025CBCA49007E54C4 /* Result+Fold.swift */; }; 15EC67D225E6217F007DFEA8 /* OSLog+Afterpay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15EC67D125E6217F007DFEA8 /* OSLog+Afterpay.swift */; }; 15F7DDB725393BD30011EC25 /* CurrencyFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F7DDB625393BD30011EC25 /* CurrencyFormatter.swift */; }; + 42DA4F9826E0740500204E75 /* IntroText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DA4F9726E0740500204E75 /* IntroText.swift */; }; 550D48152625539900C0B0C6 /* WidgetStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 550D48142625539900C0B0C6 /* WidgetStatusTests.swift */; }; 550D481B26255D8600C0B0C6 /* WidgetEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 550D481A26255D8600C0B0C6 /* WidgetEventTests.swift */; }; 5519DFAC261D38A8000628FF /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 5519DFAB261D38A8000628FF /* README.md */; }; @@ -80,6 +81,7 @@ 15EC67D125E6217F007DFEA8 /* OSLog+Afterpay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSLog+Afterpay.swift"; sourceTree = ""; }; 15F7DDB625393BD30011EC25 /* CurrencyFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyFormatter.swift; sourceTree = ""; }; 15FAC56625DCCEDF00DE7792 /* Afterpay.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = Afterpay.podspec; sourceTree = ""; }; + 42DA4F9726E0740500204E75 /* IntroText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroText.swift; sourceTree = ""; }; 550D48142625539900C0B0C6 /* WidgetStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetStatusTests.swift; sourceTree = ""; }; 550D481A26255D8600C0B0C6 /* WidgetEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetEventTests.swift; sourceTree = ""; }; 5519DFAB261D38A8000628FF /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -340,6 +342,7 @@ 6615F99A24D14620005036F1 /* SVG.swift */, 557511C226489F090040CC51 /* SVG+Source.swift */, 6672982925357D80001D1C5A /* SVGConfiguration.swift */, + 42DA4F9726E0740500204E75 /* IntroText.swift */, ); path = Resources; sourceTree = ""; @@ -569,6 +572,7 @@ 66D685B224BD3FB900C7287C /* SwiftUIWrapper.swift in Sources */, 666D334C24A48F5C00FCD464 /* ObjcWrapper.swift in Sources */, 55432830263A61C4005512E4 /* CombineWrapper.swift in Sources */, + 42DA4F9826E0740500204E75 /* IntroText.swift in Sources */, 157E88D125CBCA49007E54C4 /* Result+Fold.swift in Sources */, 55A2D307261BB36C00D8E23A /* Money.swift in Sources */, 661D4323257DF1CB00ACCDE1 /* ShippingOption.swift in Sources */, diff --git a/Example/Example/Components/ComponentsViewController.swift b/Example/Example/Components/ComponentsViewController.swift index c57aee4d..acee3f8b 100644 --- a/Example/Example/Components/ComponentsViewController.swift +++ b/Example/Example/Components/ComponentsViewController.swift @@ -158,6 +158,7 @@ private final class ContentStackViewController: UIViewController, PriceBreakdown stack.addArrangedSubview(badgeStack) let priceBreakdown1 = PriceBreakdownView() + priceBreakdown1.introText = AfterpayIntroText.payInTitle priceBreakdown1.totalAmount = 100 priceBreakdown1.delegate = self stack.addArrangedSubview(priceBreakdown1) diff --git a/README.md b/README.md index 9d6c6fb8..a3022092 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ dataStore.fetchDataRecords(ofTypes: dataTypes) { records in The checkout widget displays the consumer's payment schedule, and can be updated as the order total changes. It should be shown if the order value is going to change after the Afterpay Express checkout has finished. For example, the order total may change in response to shipping costs and promo codes. It can also be used to show if there are any barriers to completing the purchase, like if the customer has gone over their Afterpay payment limit. -It can be used in two ways: with a checkout token (from checkout v2) or with a monetary amount (also known as 'tokenless mode'). +It can be used in two ways: with a checkout token (from checkout v2) or with a monetary amount (also known as 'tokenless mode'). ```swift // With token: @@ -240,7 +240,7 @@ The Afterpay badge is a simple `UIView` that can be scaled to suit the needs of let badgeView = BadgeView() ``` -Below are examples of the badge in each of the color schemes: +Below are examples of the badge in each of the color schemes: ![Black on Mint badge][badge-black-on-mint] ![Mint on Black badge][badge-mint-on-black] ![White on Black badge][badge-white-on-black] ![Black on White badge][badge-black-on-white] ### Payment Button @@ -250,7 +250,7 @@ The Afterpay `PaymentButton` is a subclass of `UIButton` that can be scaled to s Below are examples of the button in each of the color schemes: | Mint and Black | Black and White | | -- | -- | -| ![Black on Mint button][button-black-on-mint] | ![White on Black button][button-white-on-black] | +| ![Black on Mint button][button-black-on-mint] | ![White on Black button][button-white-on-black] | | ![Mint on Black button][button-mint-on-black] | ![Black on White button][button-black-on-white] | There are also a few other kinds of payment available, with different wording: @@ -296,11 +296,25 @@ A total payment amount (represented as a Swift Decimal) must be programatically let totalAmount = Decimal(string: price) ?? .zero let priceBreakdownView = PriceBreakdownView() +priceBreakdownView.introText = AfterpayIntroText.payInTitle priceBreakdownView.totalAmount = totalAmount ``` After setting the total amount the matching breakdown string for the set Afterpay configuration will be displayed. +### Intro Text +Setting `introText` is optional, will default to `or` and must be of type `AfterpayIntroText`. + +Can be any of `or`, `orTitle`, `pay`, `payTitle`, `make`, `makeTitle`, `payIn`, `payInTitle`, `in`, `inTitle` or `NONE` (no intro text). +Intro text will be rendered lowercase unless using an option suffixed with `Title` in which case title case will be rendered. + +```swift +let priceBreakdownView = PriceBreakdownView() +priceBreakdownView.introText = AfterpayIntroText.makeTitle +``` + +Given the above, the price breakdown text will be rendered `Make 4 interest-free payments of $##.##` + ### Examples When the breakdown component is assigned a total amount that is valid for the merchant account, the component will display 4 instalment amounts. @@ -434,7 +448,7 @@ You may also choose to send the desired locale and/or environment data back from The following examples are in Swift and UIKit. Objective-C and SwiftUI wrappers have not been provided at this time for v2. Please raise an issue if you would like to see them implemented. -> **NOTE:** +> **NOTE:** > Two requirements must be met in order to use checkout v2 successfully: > - Configuration must always be set before presentation otherwise you will incur an assertionFailure. > - When creating a checkout token `popupOriginUrl` must be set to `https://static.afterpay.com`. The SDK’s example merchant server sets the parameter [here](https://github.com/afterpay/sdk-example-server/blob/master/src/routes/checkout.ts#L28). See more at by checking the [api reference][express-checkout]. Failing to do so will cause undefined behavior. @@ -554,7 +568,7 @@ WidgetView.init(amount:) ### Widget Options -The widget has appearance options. You can provide these when you initialise the `WidgetView`. +The widget has appearance options. You can provide these when you initialise the `WidgetView`. Both initialisers take an optional second parameter: a `WidgetView.Style`. The style type contains the appearance options for the widget. At the moment, the only options for `Style` are booleans for the `logo` and the `header`. By default, they are `true`. @@ -576,7 +590,7 @@ widgetView.layer.borderColor = UIColor.someOtherColor ### Updating the Widget -The order total will change due to circumstances like promo codes, shipping options, _et cetera_. When the it has changed, you should inform the widget so that it can update what it is displaying. +The order total will change due to circumstances like promo codes, shipping options, _et cetera_. When the it has changed, you should inform the widget so that it can update what it is displaying. You may send updates to the widget via its `sendUpdate(amount:)` function. The `amount` parameter is the total amount of the order. It must be in the same currency that was sent to `Afterpay.setConfiguration`. The configuration object *must* be set before calling this method, or it will throw. @@ -591,12 +605,12 @@ You can also enquire about the current status of the widget. This is an asynchro (If you wish to be informed when the status has changed, consider setting a `WidgetHandler`) ```swift -widgetView.getStatus { result in +widgetView.getStatus { result in // handle result } ``` -The `result` returned, if successful, is a `WidgetStatus`. This tells you if the widget is either in a valid or invalid state. `WidgetStatus` is an enum with two cases: `valid` and `invalid`. Each case has associated values appropriate for their circumstances. +The `result` returned, if successful, is a `WidgetStatus`. This tells you if the widget is either in a valid or invalid state. `WidgetStatus` is an enum with two cases: `valid` and `invalid`. Each case has associated values appropriate for their circumstances. `valid` has the amount of money due today and the payment schedule checksum. The checksum is a unique value representing the payment schedule that must be provided when capturing the order. `invalid` has the error code and error message. The error code and message are optional. @@ -614,7 +628,7 @@ final class ExampleWidgetHandler: WidgetHandler { } func onChanged(status: WidgetStatus) { - // The widget has had an update. + // The widget has had an update. } func onError(errorCode: String?, message: String?) { @@ -633,7 +647,7 @@ final class MyViewController: UIViewController { init() { // ... snip ... - + // Do this some time before displaying the widget. Doesn't have to be in init() Afterpay.setWidgetHandler(widgetHandler) } diff --git a/Sources/Afterpay/Model/PriceBreakdown.swift b/Sources/Afterpay/Model/PriceBreakdown.swift index 46f082e9..90d8e4cc 100644 --- a/Sources/Afterpay/Model/PriceBreakdown.swift +++ b/Sources/Afterpay/Model/PriceBreakdown.swift @@ -18,7 +18,7 @@ struct PriceBreakdown { let string: String let badgePlacement: BadgePlacement - init(totalAmount: Decimal) { + init(totalAmount: Decimal, introText: AfterpayIntroText) { let configuration = getConfiguration() let formatter = configuration .map { CurrencyFormatter(locale: $0.locale, currencyCode: $0.currencyCode) } @@ -35,7 +35,7 @@ struct PriceBreakdown { if let formattedPayment = formattedPayment, inRange { badgePlacement = .end - string = String(format: Strings.fourPaymentsFormat, formattedPayment) + string = String(format: Strings.fourPaymentsFormat, introText.rawValue, formattedPayment) } else if let formattedMinimum = formattedMinimum, let formattedMaximum = formattedMaximum { badgePlacement = .start string = String(format: Strings.availableBetweenFormat, formattedMinimum, formattedMaximum) diff --git a/Sources/Afterpay/Resources/IntroText.swift b/Sources/Afterpay/Resources/IntroText.swift new file mode 100644 index 00000000..ded513ed --- /dev/null +++ b/Sources/Afterpay/Resources/IntroText.swift @@ -0,0 +1,23 @@ +// +// IntroText.swift +// Afterpay +// +// Created by Scott Antonac on 25/8/21. +// Copyright © 2021 Afterpay. All rights reserved. +// + +import Foundation + +public enum AfterpayIntroText: String { + case NONE = "" + case make = "make " + case makeTitle = "Make " + case pay = "pay " + case payTitle = "Pay " + case `in` = "in " + case inTitle = "In " + case or = "or " + case orTitle = "Or " + case payIn = "pay in " + case payInTitle = "Pay in " +} diff --git a/Sources/Afterpay/Resources/Strings.swift b/Sources/Afterpay/Resources/Strings.swift index 9623cb4b..5442e461 100644 --- a/Sources/Afterpay/Resources/Strings.swift +++ b/Sources/Afterpay/Resources/Strings.swift @@ -21,7 +21,7 @@ enum Strings { static let availableBetweenFormat = "available for orders between %@ - %@" static let availableUpToFormat = "available for orders up to %@" - static let fourPaymentsFormat = "or 4 interest-free payments of %@ with" + static let fourPaymentsFormat = "%@4 interest-free payments of %@ with" // MARK: - Accessible Strings diff --git a/Sources/Afterpay/Views/PriceBreakdownView.swift b/Sources/Afterpay/Views/PriceBreakdownView.swift index 2630b328..fdfa46d2 100644 --- a/Sources/Afterpay/Views/PriceBreakdownView.swift +++ b/Sources/Afterpay/Views/PriceBreakdownView.swift @@ -36,6 +36,12 @@ public final class PriceBreakdownView: UIView { } } + public var introText: AfterpayIntroText = AfterpayIntroText.or { + didSet { + updateAttributedText() + } + } + public var textColor: UIColor = { if #available(iOS 13.0, *) { return .label @@ -153,7 +159,7 @@ public final class PriceBreakdownView: UIView { let space = NSAttributedString(string: " ", attributes: textAttributes) - let priceBreakdown = PriceBreakdown(totalAmount: totalAmount) + let priceBreakdown = PriceBreakdown(totalAmount: totalAmount, introText: introText) let breakdown = NSAttributedString(string: priceBreakdown.string, attributes: textAttributes) let badgePlacement = priceBreakdown.badgePlacement From 74abaa788da1466ed2e5a2bcd42bd44ac317e572 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Thu, 2 Sep 2021 14:55:13 +1000 Subject: [PATCH 2/6] add a default for the introText parameter to the PriceBreakdown initialiser --- Sources/Afterpay/Model/PriceBreakdown.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Afterpay/Model/PriceBreakdown.swift b/Sources/Afterpay/Model/PriceBreakdown.swift index 90d8e4cc..5d6ad8a5 100644 --- a/Sources/Afterpay/Model/PriceBreakdown.swift +++ b/Sources/Afterpay/Model/PriceBreakdown.swift @@ -18,7 +18,10 @@ struct PriceBreakdown { let string: String let badgePlacement: BadgePlacement - init(totalAmount: Decimal, introText: AfterpayIntroText) { + init( + totalAmount: Decimal, + introText: AfterpayIntroText = AfterpayIntroText.or + ) { let configuration = getConfiguration() let formatter = configuration .map { CurrencyFormatter(locale: $0.locale, currencyCode: $0.currencyCode) } From 0a4e72a8d095add9654d8877ac921218816f7513 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Fri, 3 Sep 2021 08:06:10 +1000 Subject: [PATCH 3/6] address requested changes regarding intro text - change shouty NONE to empty - clean up template string for better readibility and easier maintenance of intro text options --- README.md | 2 +- Sources/Afterpay/Model/PriceBreakdown.swift | 2 +- Sources/Afterpay/Resources/IntroText.swift | 22 ++++++++++----------- Sources/Afterpay/Resources/Strings.swift | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a3022092..956147d3 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ After setting the total amount the matching breakdown string for the set Afterpa ### Intro Text Setting `introText` is optional, will default to `or` and must be of type `AfterpayIntroText`. -Can be any of `or`, `orTitle`, `pay`, `payTitle`, `make`, `makeTitle`, `payIn`, `payInTitle`, `in`, `inTitle` or `NONE` (no intro text). +Can be any of `or`, `orTitle`, `pay`, `payTitle`, `make`, `makeTitle`, `payIn`, `payInTitle`, `in`, `inTitle` or `empty` (no intro text). Intro text will be rendered lowercase unless using an option suffixed with `Title` in which case title case will be rendered. ```swift diff --git a/Sources/Afterpay/Model/PriceBreakdown.swift b/Sources/Afterpay/Model/PriceBreakdown.swift index 5d6ad8a5..8fb7d951 100644 --- a/Sources/Afterpay/Model/PriceBreakdown.swift +++ b/Sources/Afterpay/Model/PriceBreakdown.swift @@ -38,7 +38,7 @@ struct PriceBreakdown { if let formattedPayment = formattedPayment, inRange { badgePlacement = .end - string = String(format: Strings.fourPaymentsFormat, introText.rawValue, formattedPayment) + string = String(format: Strings.fourPaymentsFormat, introText.rawValue, formattedPayment).trimmingCharacters(in: .whitespaces) } else if let formattedMinimum = formattedMinimum, let formattedMaximum = formattedMaximum { badgePlacement = .start string = String(format: Strings.availableBetweenFormat, formattedMinimum, formattedMaximum) diff --git a/Sources/Afterpay/Resources/IntroText.swift b/Sources/Afterpay/Resources/IntroText.swift index ded513ed..aa8dec48 100644 --- a/Sources/Afterpay/Resources/IntroText.swift +++ b/Sources/Afterpay/Resources/IntroText.swift @@ -9,15 +9,15 @@ import Foundation public enum AfterpayIntroText: String { - case NONE = "" - case make = "make " - case makeTitle = "Make " - case pay = "pay " - case payTitle = "Pay " - case `in` = "in " - case inTitle = "In " - case or = "or " - case orTitle = "Or " - case payIn = "pay in " - case payInTitle = "Pay in " + case empty = "" + case make = "make" + case makeTitle = "Make" + case pay = "pay" + case payTitle = "Pay" + case `in` = "in" + case inTitle = "In" + case or = "or" + case orTitle = "Or" + case payIn = "pay in" + case payInTitle = "Pay in" } diff --git a/Sources/Afterpay/Resources/Strings.swift b/Sources/Afterpay/Resources/Strings.swift index 5442e461..b83c421b 100644 --- a/Sources/Afterpay/Resources/Strings.swift +++ b/Sources/Afterpay/Resources/Strings.swift @@ -21,7 +21,7 @@ enum Strings { static let availableBetweenFormat = "available for orders between %@ - %@" static let availableUpToFormat = "available for orders up to %@" - static let fourPaymentsFormat = "%@4 interest-free payments of %@ with" + static let fourPaymentsFormat = "%@ 4 interest-free payments of %@ with" // MARK: - Accessible Strings From 73b41c98c848a9953589e2fe243e49926cccc751 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Fri, 3 Sep 2021 08:47:53 +1000 Subject: [PATCH 4/6] address line length linting in price breakdown --- Sources/Afterpay/Model/PriceBreakdown.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Afterpay/Model/PriceBreakdown.swift b/Sources/Afterpay/Model/PriceBreakdown.swift index 8fb7d951..ea8e816c 100644 --- a/Sources/Afterpay/Model/PriceBreakdown.swift +++ b/Sources/Afterpay/Model/PriceBreakdown.swift @@ -38,7 +38,8 @@ struct PriceBreakdown { if let formattedPayment = formattedPayment, inRange { badgePlacement = .end - string = String(format: Strings.fourPaymentsFormat, introText.rawValue, formattedPayment).trimmingCharacters(in: .whitespaces) + string = String(format: Strings.fourPaymentsFormat, introText.rawValue, formattedPayment) + .trimmingCharacters(in: .whitespaces) } else if let formattedMinimum = formattedMinimum, let formattedMaximum = formattedMaximum { badgePlacement = .start string = String(format: Strings.availableBetweenFormat, formattedMinimum, formattedMaximum) From c9ec82120b8b614ebbe47e6d074fdd2b4dfc3d84 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Mon, 6 Sep 2021 11:39:07 +1000 Subject: [PATCH 5/6] bump dependencies --- .../xcshareddata/swiftpm/Package.resolved | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7ba50039..2ff8179d 100644 --- a/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Afterpay.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "repositoryURL": "https://github.com/drmohundro/SWXMLHash", "state": { "branch": null, - "revision": "a4931e5c3bafbedeb1601d3bb76bbe835c6d475a", - "version": "5.0.1" + "revision": "9183170d20857753d4f331b0ca63f73c60764bf3", + "version": "5.0.2" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/datatheorem/TrustKit", "state": { "branch": null, - "revision": "714fd3fcdcada5b107d91bf6caaaefb00f792730", - "version": "1.6.5" + "revision": "3c953558d61fdd9b136d981764e3242bd92b2648", + "version": "1.7.0" } } ] From 1d797a4256cae817ba0f3f4822037df0e41b9028 Mon Sep 17 00:00:00 2001 From: Scott Antonac Date: Mon, 6 Sep 2021 11:46:09 +1000 Subject: [PATCH 6/6] bump Afterpay version to 3.0.4 --- Configurations/Afterpay-Shared.xcconfig | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Configurations/Afterpay-Shared.xcconfig b/Configurations/Afterpay-Shared.xcconfig index 32c120c2..1500a591 100644 --- a/Configurations/Afterpay-Shared.xcconfig +++ b/Configurations/Afterpay-Shared.xcconfig @@ -169,4 +169,4 @@ TARGETED_DEVICE_FAMILY = 1,2 // This setting defines the user-visible version of the project. The value corresponds to // the `CFBundleShortVersionString` key in your app's Info.plist. -MARKETING_VERSION = 3.0.3 +MARKETING_VERSION = 3.0.4 diff --git a/README.md b/README.md index 956147d3..29034aac 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ This is the recommended integration method. ``` dependencies: [ - .package(url: "https://github.com/afterpay/sdk-ios.git", .upToNextMajor(from: "3.0.3")) + .package(url: "https://github.com/afterpay/sdk-ios.git", .upToNextMajor(from: "3.0.4")) ] ``` @@ -109,7 +109,7 @@ Add the Afterpay SDK as a [git submodule][git-submodule] by navigating to the ro ``` git submodule add https://github.com/afterpay/sdk-ios.git Afterpay cd Afterpay -git checkout 3.0.3 +git checkout 3.0.4 ``` #### Project / Workspace Integration