Commit c6941b5e authored by Jose Blaya's avatar Jose Blaya
Browse files

Merge branch '61-piax-22-purchase-a-vpn-plan' into '60-piax-12-redeem-gift-card'

Resolve "PIAX 22. Purchase a VPN plan"

See merge request ios/client-library-apple!117
parents cfd95e82 a01594f0
......@@ -262,6 +262,7 @@
DD31498F21834B3F008E26E8 /* GetStartedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD31498E21834B3F008E26E8 /* GetStartedViewController.swift */; };
DD314990218350D1008E26E8 /* SwiftGen+ScenesStoryboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC812472176166500CB290C /* SwiftGen+ScenesStoryboards.swift */; };
DD314991218350D1008E26E8 /* SwiftGen+SeguesStoryboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC81249217617F900CB290C /* SwiftGen+SeguesStoryboards.swift */; };
DD8BF3CB219C6BAA0041357C /* ConfirmVPNPlanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8BF3CA219C6BAA0041357C /* ConfirmVPNPlanViewController.swift */; };
DDD824E32189969400151709 /* Preset.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD824E22189969400151709 /* Preset.swift */; };
DDD824E5218996CD00151709 /* Pages.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD824E4218996CD00151709 /* Pages.swift */; };
DDD824E72189C0E800151709 /* BrandableNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD824E62189C0E800151709 /* BrandableNavigationBar.swift */; };
......@@ -519,6 +520,7 @@
DA1A1A4FDD6B854C1227A5F0 /* Pods_PIALibrary_PIALibraryHost_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_PIALibrary_PIALibraryHost_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DD0AC78F218715B8009B576B /* PIAButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PIAButton.swift; sourceTree = "<group>"; };
DD31498E21834B3F008E26E8 /* GetStartedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetStartedViewController.swift; sourceTree = "<group>"; };
DD8BF3CA219C6BAA0041357C /* ConfirmVPNPlanViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmVPNPlanViewController.swift; sourceTree = "<group>"; };
DDC812472176166500CB290C /* SwiftGen+ScenesStoryboards.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftGen+ScenesStoryboards.swift"; sourceTree = "<group>"; };
DDC81249217617F900CB290C /* SwiftGen+SeguesStoryboards.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftGen+SeguesStoryboards.swift"; sourceTree = "<group>"; };
DDD824E22189969400151709 /* Preset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preset.swift; sourceTree = "<group>"; };
......@@ -1059,6 +1061,7 @@
0EB8C05A1F9CD38A005857E4 /* SignupSuccessViewController.swift */,
0EB8C05B1F9CD38A005857E4 /* WelcomePageViewController.swift */,
DD31498E21834B3F008E26E8 /* GetStartedViewController.swift */,
DD8BF3CA219C6BAA0041357C /* ConfirmVPNPlanViewController.swift */,
);
path = ViewControllers;
sourceTree = "<group>";
......@@ -1681,6 +1684,7 @@
0EB8C0641F9CD38B005857E4 /* ActivityButton.swift in Sources */,
0ED2B5131F82444E00C9DB2B /* Client+Preferences.swift in Sources */,
0EE78AF61F81880E002E4CDD /* Credentials.swift in Sources */,
DD8BF3CB219C6BAA0041357C /* ConfirmVPNPlanViewController.swift in Sources */,
0E53A83E1FE5A4C8000C2A18 /* Client+Daemons.swift in Sources */,
0E4D4EA01FA4CA7A007DA6DA /* Validator.swift in Sources */,
0E0E5B111F8297D200022CD0 /* UserDefaultsStore.swift in Sources */,
......
{
"images" : [
{
"idiom" : "universal",
"filename" : "group3.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
\ No newline at end of file
{
"images" : [
{
"idiom" : "universal",
"filename" : "oval3.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
\ No newline at end of file
......@@ -23,14 +23,16 @@
"purchase.title" = "Select a VPN plan";
"purchase.subtitle" = "7-day money back guarantee";
"purchase.email.placeholder" = "Email address";
"purchase.submit" = "BUY NOW";
"purchase.continue" = "Continue";
"purchase.login.footer" = "Already have an account?";
"purchase.login.button" = "Sign in";
"purchase.error.title" = "Purchase";
"purchase.error.validation" = "You must enter an email address.";
"purchase.error.connectivity.title" = "Connection Failure";
"purchase.error.connectivity.description" = "We are unable to reach Private Internet Access. This may due to poor internet or our service is blocked in your country.";
"purchase.confirm.form.email" = "Enter your email address";
"purchase.confirm.plan" = "Your are purchasing the %@ plan";
"purchase.submit" = "Submit";
"redeem.title" = "Redeem gift card";
"redeem.subtitle" = "Type in your email address and the %lu digit PIN from your gift card or trial card below.";
......
......@@ -12,30 +12,28 @@ class PurchasePlanCell: UICollectionViewCell, Restylable {
// XXX
private static let textPlaceholder = " "
private static let pricePlaceholder = " "
private static let bestValueContainerHeight: CGFloat = 20.0
private static let priceBottomConstant: CGFloat = 26.0
@IBOutlet private weak var viewContainer: UIView!
@IBOutlet private weak var viewHeader: UIView!
@IBOutlet private weak var viewBestValue: UIView!
@IBOutlet private weak var labelBestValue: UILabel!
@IBOutlet private weak var labelPlan: UILabel!
@IBOutlet private weak var viewSpacer: UIView!
@IBOutlet private weak var labelPrice: UILabel!
@IBOutlet private weak var labelDetail: UILabel!
@IBOutlet private weak var unselectedPlanImageView: UIImageView!
@IBOutlet private weak var selectedPlanImageView: UIImageView!
@IBOutlet private weak var bestValueHeightConstraint: NSLayoutConstraint!
@IBOutlet weak var priceBottomConstraint: NSLayoutConstraint!
override func awakeFromNib() {
super.awakeFromNib()
isSelected = false
labelBestValue.text = L10n.Welcome.Plan.bestValue
labelBestValue.text = L10n.Welcome.Plan.bestValue.uppercased()
selectedPlanImageView.alpha = 0
}
func fill(plan: PurchasePlan) {
......@@ -60,6 +58,15 @@ class PurchasePlanCell: UICollectionViewCell, Restylable {
labelDetail.text = plan.detail
labelPrice.text = L10n.Welcome.Plan.priceFormat(plan.monthlyPriceString)
viewBestValue.isHidden = !plan.bestValue
if viewBestValue.isHidden {
bestValueHeightConstraint.constant = 0
priceBottomConstraint.constant = 0
} else {
bestValueHeightConstraint.constant = PurchasePlanCell.bestValueContainerHeight
priceBottomConstraint.constant = PurchasePlanCell.priceBottomConstant
}
self.layoutSubviews()
accessibilityLabel = "\(plan.title), \(plan.accessibleMonthlyPriceString) \(L10n.Welcome.Plan.Accessibility.perMonth)"
}
......@@ -72,13 +79,13 @@ class PurchasePlanCell: UICollectionViewCell, Restylable {
// Theme.current.applyTitle(labelPrice, appearance:(isSelected ? .emphasis : .dark))
if isSelected {
Theme.current.applySolidSelection(viewHeader)
Theme.current.applyTitle(labelPlan, appearance: .light)
viewSpacer.isHidden = true
UIView.animate(withDuration: 0.2, animations: {
self.selectedPlanImageView.alpha = 1
})
} else {
viewHeader.backgroundColor = .clear
Theme.current.applyTitle(labelPlan, appearance: .dark)
viewSpacer.isHidden = false
UIView.animate(withDuration: 0.2, animations: {
self.selectedPlanImageView.alpha = 0
})
}
}
}
......@@ -86,11 +93,11 @@ class PurchasePlanCell: UICollectionViewCell, Restylable {
// MARK: Restylable
func viewShouldRestyle() {
Theme.current.applyCorner(viewBestValue, factor: 0.5)
Theme.current.applyCorner(viewBestValue, factor: 1.0)
Theme.current.applyWarningBackground(viewBestValue)
Theme.current.applyTag(labelBestValue, appearance: .light)
Theme.current.applyDivider(viewSpacer)
Theme.current.applyBlackLabelInBox(labelBestValue)
Theme.current.applySubtitle(labelPlan)
Theme.current.applyTitle(labelPrice, appearance: .dark)
Theme.current.applySmallInfo(labelDetail, appearance: .dark)
Theme.current.applySubtitle(labelDetail)
}
}
......@@ -25,6 +25,8 @@ internal enum Asset {
internal static let iconWarning = ImageAsset(name: "icon-warning")
internal static let logoDark = ImageAsset(name: "logo-dark")
internal static let logoLight = ImageAsset(name: "logo-light")
internal static let planSelected = ImageAsset(name: "plan-selected")
internal static let planUnselected = ImageAsset(name: "plan-unselected")
internal static let closeIcon = ImageAsset(name: "close-icon")
internal static let imageAccountFailed = ImageAsset(name: "image-account-failed")
internal static let imageNoInternet = ImageAsset(name: "image-no-internet")
......
......@@ -20,6 +20,8 @@ internal enum StoryboardSegue {
case unwindInternetUnreachableSegueIdentifier = "UnwindInternetUnreachableSegueIdentifier"
}
internal enum Welcome: String, SegueType {
case confirmPurchaseVPNPlanSegue = "ConfirmPurchaseVPNPlanSegue"
case purchaseVPNPlanSegue = "PurchaseVPNPlanSegue"
case redeemGiftCardSegue = "RedeemGiftCardSegue"
case signupQRCameraScannerSegue = "SignupQRCameraScannerSegue"
case signupViaPurchaseSegue = "SignupViaPurchaseSegue"
......
......@@ -203,12 +203,24 @@ internal enum L10n {
}
}
internal enum Purchase {
/// BUY NOW
/// Continue
internal static let `continue` = L10n.tr("Welcome", "purchase.continue")
/// Submit
internal static let submit = L10n.tr("Welcome", "purchase.submit")
/// 7-day money back guarantee
internal static let subtitle = L10n.tr("Welcome", "purchase.subtitle")
/// Select a VPN plan
internal static let title = L10n.tr("Welcome", "purchase.title")
internal enum Confirm {
/// Your are purchasing the %@ plan
internal static func plan(_ p1: String) -> String {
return L10n.tr("Welcome", "purchase.confirm.plan", p1)
}
internal enum Form {
/// Enter your email address
internal static let email = L10n.tr("Welcome", "purchase.confirm.form.email")
}
}
internal enum Email {
/// Email address
internal static let placeholder = L10n.tr("Welcome", "purchase.email.placeholder")
......
......@@ -24,7 +24,7 @@ extension Theme.Palette {
palette.lineColor = .piaGreenDark20
palette.subtitleColor = .piaGrey8
palette.emphasis = Macros.color(hex: 0x29cc41, alpha: 0xff)
palette.accent1 = Macros.color(hex: 0xf7941d, alpha: 0xff)
palette.accent1 = UIColor.piaOrange
palette.accent2 = Macros.color(hex: 0xe60924, alpha: 0xff)
palette.setDarkText(Macros.color(hex: 0x001b31, alpha: 0xff), alphas: [0.87, 0.67, 0.37])
palette.setLightText(.white, alphas: [0.87, 0.67, 0.37])
......
......@@ -101,7 +101,7 @@ public class Theme {
lineColor = .piaGreenDark20
// primary = .black
emphasis = .green
accent1 = .orange
accent1 = .piaOrange
accent2 = .red
darkText = .black
......@@ -472,6 +472,12 @@ public class Theme {
label.textColor = palette.textColor(forRelevance: 1, appearance: appearance)
}
/// :nodoc:
public func applyBlackLabelInBox(_ label: UILabel) {
label.font = typeface.regularFont(size: 12.0)
label.textColor = .black
}
/// :nodoc:
public func applyList(_ label: UILabel, appearance: Appearance) {
applyList(label, appearance: appearance, relevance: 2)
......
......@@ -17,6 +17,13 @@ public protocol ModalController: class {
func dismissModal()
}
/// Enum used to determinate the status of the view controller and apply effects over the UI elements
public enum ViewControllerStatus {
case initial
case restore(element: UIView)
case error(element: UIView)
}
/// Base view controller with dynamic constraints and restyling support.
///
/// - Seealso: `Theme`
......@@ -32,6 +39,12 @@ open class AutolayoutViewController: UIViewController, ModalController, Restylab
return Theme.current.statusBarAppearance(for: self)
}
/// The initial status of the view controller. Every time the var changes the value, we reload the UI of the form element given as parameter.
/// Example of use: self.status = .error(element: textEmail)
open var status: ViewControllerStatus = .initial {
didSet { reloadFormElements() }
}
deinit {
NotificationCenter.default.removeObserver(self)
}
......@@ -94,4 +107,28 @@ open class AutolayoutViewController: UIViewController, ModalController, Restylab
}
setNeedsStatusBarAppearanceUpdate()
}
private func reloadFormElements() {
switch status {
case .initial:
break
case .restore(let element):
restoreFormElementBorder(element)
case .error(let element):
updateFormElementBorder(element)
}
}
private func restoreFormElementBorder(_ element: UIView) {
if let element = element as? UITextField {
Theme.current.applyInput(element)
}
}
private func updateFormElementBorder(_ element: UIView) {
if let element = element as? UITextField {
Theme.current.applyInputError(element)
}
}
}
//
// ConfirmVPNPlanViewController.swift
// PIALibrary-iOS
//
// Created by Jose Antonio Blaya Garcia on 14/11/2018.
// Copyright © 2018 London Trust Media. All rights reserved.
//
import UIKit
import SwiftyBeaver
private let log = SwiftyBeaver.self
public class ConfirmVPNPlanViewController: AutolayoutViewController, BrandableNavigationBar, WelcomeChild {
@IBOutlet private weak var buttonConfirm: PIAButton!
@IBOutlet private weak var textEmail: BorderedTextField!
@IBOutlet private weak var textAgreement: UITextView!
@IBOutlet private weak var labelTitle: UILabel!
@IBOutlet private weak var labelSubtitle: UILabel!
private var signupEmail: String?
private var signupTransaction: InAppTransaction?
weak var completionDelegate: WelcomeCompletionDelegate?
var omitsSiblingLink = false
var preset: Preset?
private var allPlans: [PurchasePlan] = [.dummy, .dummy]
private var selectedPlanIndex: Int?
deinit {
NotificationCenter.default.removeObserver(self)
}
override public func viewDidLoad() {
super.viewDidLoad()
guard let preset = self.preset else {
fatalError("Preset not propagated")
}
guard let planIndex = selectedPlanIndex else {
return
}
self.navigationItem.leftBarButtonItem = UIBarButtonItem(
image: Theme.current.palette.navigationBarBackIcon?.withRenderingMode(.alwaysOriginal),
style: .plain,
target: self,
action: #selector(back(_:))
)
self.navigationItem.leftBarButtonItem?.accessibilityLabel = L10n.Welcome.Redeem.Accessibility.back
labelTitle.text = L10n.Welcome.Purchase.Confirm.Form.email
let plan = allPlans[planIndex]
labelSubtitle.text = L10n.Welcome.Purchase.Confirm.plan(plan.title.lowercased())
textAgreement.attributedText = Theme.current.agreementText(
withMessage: L10n.Welcome.Agreement.message,
tos: L10n.Welcome.Agreement.Message.tos,
tosUrl: Client.configuration.tosUrl,
privacy: L10n.Welcome.Agreement.Message.privacy,
privacyUrl: Client.configuration.privacyUrl
)
textEmail.placeholder = L10n.Welcome.Purchase.Email.placeholder
textEmail.text = preset.purchaseEmail
self.styleConfirmButton()
}
/// Populate the view with the values from PurchaseViewController
/// - Parameters:
/// - plans: The available plans.
/// - selectedIndex: The selected plan from the previous screen.
func populateViewWith(plans: [PurchasePlan], andSelectedPlanIndex selectedIndex: Int) {
self.allPlans = plans
self.selectedPlanIndex = selectedIndex
}
@IBAction private func signUp(_ sender: Any?) {
guard let planIndex = selectedPlanIndex else {
return
}
guard let email = textEmail.text?.trimmed(), Validator.validate(email: email) else {
signupEmail = nil
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: L10n.Welcome.Purchase.Error.validation)
self.status = .error(element: textEmail)
return
}
self.status = .restore(element: textEmail)
let plan = allPlans[planIndex]
guard !plan.isDummy else {
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: L10n.Welcome.Iap.Error.Message.unavailable)
return
}
disableInteractions()
preset?.accountProvider.isAPIEndpointAvailable({ [weak self] (isAvailable, error) in
self?.enableInteractions()
guard let isAvailable = isAvailable,
isAvailable else {
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: L10n.Welcome.Purchase.Error.Connectivity.description)
return
}
self?.startPurchaseProcessWithEmail(email,
andPlan: plan)
})
}
@objc private func back(_ sender: Any?) {
self.navigationController?.popViewController(animated: true)
}
private func disableInteractions() {
parent?.view.isUserInteractionEnabled = false
}
private func enableInteractions() {
parent?.view.isUserInteractionEnabled = true
}
private func startPurchaseProcessWithEmail(_ email: String,
andPlan plan: PurchasePlan) {
//textEmail.text = email
log.debug("Will purchase plan: \(plan.product)")
disableInteractions()
preset?.accountProvider.purchase(plan: plan.plan) { (transaction, error) in
self.enableInteractions()
guard let transaction = transaction else {
if let error = error {
log.error("Purchase failed (error: \(error))")
Macros.displayImageNote(withImage: Asset.iconWarning.image,
message: error.localizedDescription)
} else {
log.warning("Cancelled purchase")
}
return
}
log.debug("Purchased with transaction: \(transaction)")
self.signupEmail = email
self.signupTransaction = transaction
self.perform(segue: StoryboardSegue.Welcome.signupViaPurchaseSegue)
}
}
override public func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if (segue.identifier == StoryboardSegue.Welcome.signupViaPurchaseSegue.rawValue) {
let nav = segue.destination as! UINavigationController
let vc = nav.topViewController as! SignupInProgressViewController
guard let email = signupEmail else {
fatalError("Signing up and signupEmail is not set")
}
var metadata = SignupMetadata(email: email)
metadata.title = L10n.Signup.InProgress.title
metadata.bodySubtitle = L10n.Signup.InProgress.message
vc.metadata = metadata
vc.signupRequest = SignupRequest(email: email, transaction: signupTransaction)
vc.preset = preset
vc.completionDelegate = completionDelegate
}
}
// MARK: Restylable
override public func viewShouldRestyle() {
super.viewShouldRestyle()
navigationItem.titleView = NavigationLogoView()
Theme.current.applyNavigationBarStyle(to: self)
Theme.current.applyLightBackground(view)
Theme.current.applyInput(textEmail)
Theme.current.applyTitle(labelTitle, appearance: .dark)
Theme.current.applySubtitle(labelSubtitle)
Theme.current.applyLinkAttributes(textAgreement)
}
private func styleConfirmButton() {
buttonConfirm.setRounded()
buttonConfirm.style(style: TextStyle.Buttons.piaGreenButton)
buttonConfirm.setTitle(L10n.Welcome.Purchase.submit.uppercased(),
for: [])
}
}
......@@ -49,14 +49,20 @@ public class GetStartedViewController: AutolayoutViewController, ConfigurationAc
}
public override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let vc = segue.destination as? PIAWelcomeViewController else {
return
}
switch segue.identifier {
case StoryboardSegue.Welcome.redeemGiftCardSegue.rawValue:
if let vc = segue.destination as? PIAWelcomeViewController {
vc.preset.pages = .redeem
}
case StoryboardSegue.Welcome.purchaseVPNPlanSegue.rawValue:
vc.preset.pages = .purchase
default:
break
}
}
/// :nodoc:
......
......@@ -12,6 +12,7 @@ import SwiftyBeaver
private let log = SwiftyBeaver.self
class PurchaseViewController: AutolayoutViewController, WelcomeChild {
private struct Cells {
static let plan = "PlanCell"
}
......@@ -19,36 +20,21 @@ class PurchaseViewController: AutolayoutViewController, WelcomeChild {
@IBOutlet private weak var scrollView: UIScrollView!
@IBOutlet private weak var labelTitle: UILabel!
@IBOutlet private weak var textEmail: BorderedTextField!
@IBOutlet private weak var labelSubtitle: UILabel!
@IBOutlet private weak var collectionPlans: UICollectionView!
@IBOutlet private weak var textAgreement: UITextView!
@IBOutlet private weak var buttonPurchase: ActivityButton!
@IBOutlet private weak var viewFooter: UIView!
@IBOutlet private weak var viewLogin: UIView!
@IBOutlet private weak var labelLogin1: UILabel!
@IBOutlet private weak var labelLogin2: UILabel!
@IBOutlet private weak var buttonPurchase: PIAButton!
var preset: Preset?
var omitsSiblingLink = false