blob: a0b1fd842f139999dcda12150753587de3987881 [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "ios/chrome/browser/ui/autofill/card_unmask_prompt_view_bridge.h"
#include "base/bind.h"
#include "base/ios/ios_util.h"
#include "base/location.h"
#include "base/mac/foundation_util.h"
#include "base/single_thread_task_runner.h"
#include "base/strings/sys_string_conversions.h"
#include "base/threading/thread_task_runner_handle.h"
#include "components/autofill/core/browser/ui/card_unmask_prompt_controller.h"
#include "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/ui/autofill/cells/cvc_item.h"
#import "ios/chrome/browser/ui/autofill/cells/status_item.h"
#import "ios/chrome/browser/ui/autofill/cells/storage_switch_item.h"
#import "ios/chrome/browser/ui/autofill/storage_switch_tooltip.h"
#import "ios/chrome/browser/ui/collection_view/cells/MDCCollectionViewCell+Chrome.h"
#import "ios/chrome/browser/ui/collection_view/cells/collection_view_item.h"
#import "ios/chrome/browser/ui/collection_view/collection_view_controller.h"
#import "ios/chrome/browser/ui/collection_view/collection_view_model.h"
#import "ios/chrome/browser/ui/colors/MDCPalette+CrAdditions.h"
#import "ios/chrome/browser/ui/rtl_geometry.h"
#import "ios/third_party/material_components_ios/src/components/AppBar/src/MaterialAppBar.h"
#import "ios/third_party/material_components_ios/src/components/Palettes/src/MaterialPalettes.h"
#include "ui/base/l10n/l10n_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
namespace {
const CGFloat kTitleVerticalSpacing = 2.0f;
typedef NS_ENUM(NSInteger, SectionIdentifier) {
SectionIdentifierMain = kSectionIdentifierEnumZero,
};
typedef NS_ENUM(NSInteger, ItemType) {
ItemTypeCVC = kItemTypeEnumZero,
ItemTypeStatus,
ItemTypeStorageSwitch,
};
} // namespace
namespace autofill {
#pragma mark CardUnmaskPromptViewBridge
CardUnmaskPromptViewBridge::CardUnmaskPromptViewBridge(
CardUnmaskPromptController* controller)
: controller_(controller), weak_ptr_factory_(this) {
DCHECK(controller_);
}
CardUnmaskPromptViewBridge::~CardUnmaskPromptViewBridge() {
if (controller_)
controller_->OnUnmaskDialogClosed();
}
void CardUnmaskPromptViewBridge::Show() {
view_.reset([[CardUnmaskPromptViewIOS alloc] initWithBridge:this]);
// Present the view controller.
UIViewController* rootController =
[UIApplication sharedApplication].keyWindow.rootViewController;
[rootController presentViewController:view_ animated:YES completion:nil];
}
void CardUnmaskPromptViewBridge::ControllerGone() {
controller_ = nullptr;
PerformClose();
}
void CardUnmaskPromptViewBridge::DisableAndWaitForVerification() {
[view_ showSpinner];
}
void CardUnmaskPromptViewBridge::GotVerificationResult(
const base::string16& error_message,
bool allow_retry) {
if (error_message.empty()) {
[view_ showSuccess];
base::ThreadTaskRunnerHandle::Get()->PostDelayedTask(
FROM_HERE, base::Bind(&CardUnmaskPromptViewBridge::PerformClose,
weak_ptr_factory_.GetWeakPtr()),
controller_->GetSuccessMessageDuration());
} else {
if (allow_retry) {
[view_ showCVCInputFormWithError:SysUTF16ToNSString(error_message)];
} else {
[view_ showError:SysUTF16ToNSString(error_message)];
}
}
}
CardUnmaskPromptController* CardUnmaskPromptViewBridge::GetController() {
return controller_;
}
void CardUnmaskPromptViewBridge::PerformClose() {
[view_ dismissViewControllerAnimated:YES
completion:^{
this->DeleteSelf();
}];
}
void CardUnmaskPromptViewBridge::DeleteSelf() {
delete this;
}
} // autofill
@interface CardUnmaskPromptViewIOS ()<UITextFieldDelegate> {
UIBarButtonItem* _cancelButton;
UIBarButtonItem* _verifyButton;
CVCItem* _CVCItem;
StatusItem* _statusItem;
StorageSwitchItem* _storageSwitchItem;
// The tooltip is added as a child of the collection view rather than the
// StorageSwitchContentView to allow it to overflow the bounds of the switch
// view.
StorageSwitchTooltip* _storageSwitchTooltip;
// Owns |self|.
autofill::CardUnmaskPromptViewBridge* _bridge; // weak
}
@end
@implementation CardUnmaskPromptViewIOS
- (instancetype)initWithBridge:(autofill::CardUnmaskPromptViewBridge*)bridge {
DCHECK(bridge);
self = [super initWithStyle:CollectionViewControllerStyleAppBar];
if (self) {
_bridge = bridge;
}
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.styler.cellStyle = MDCCollectionViewCellStyleCard;
UILabel* titleLabel = [[UILabel alloc] initWithFrame:CGRectZero];
titleLabel.text =
SysUTF16ToNSString(_bridge->GetController()->GetWindowTitle());
titleLabel.font = [UIFont boldSystemFontOfSize:16];
titleLabel.accessibilityTraits |= UIAccessibilityTraitHeader;
[titleLabel sizeToFit];
UIView* titleView = [[UIView alloc] initWithFrame:CGRectZero];
[titleView addSubview:titleLabel];
CGRect titleBounds = titleView.bounds;
titleBounds.origin.y -= kTitleVerticalSpacing;
titleView.bounds = titleBounds;
titleView.autoresizingMask = UIViewAutoresizingFlexibleLeadingMargin() |
UIViewAutoresizingFlexibleBottomMargin;
self.appBar.navigationBar.titleView = titleView;
[self showCVCInputForm];
// Add the navigation buttons.
_cancelButton =
[[UIBarButtonItem alloc] initWithTitle:l10n_util::GetNSString(IDS_CANCEL)
style:UIBarButtonItemStylePlain
target:self
action:@selector(onCancel:)];
self.navigationItem.leftBarButtonItem = _cancelButton;
NSString* verifyButtonText =
SysUTF16ToNSString(_bridge->GetController()->GetOkButtonLabel());
_verifyButton =
[[UIBarButtonItem alloc] initWithTitle:verifyButtonText
style:UIBarButtonItemStylePlain
target:self
action:@selector(onVerify:)];
[_verifyButton setTitleTextAttributes:@{
NSForegroundColorAttributeName : [[MDCPalette cr_bluePalette] tint600]
}
forState:UIControlStateNormal];
[_verifyButton setTitleTextAttributes:@{
NSForegroundColorAttributeName : [UIColor lightGrayColor]
}
forState:UIControlStateDisabled];
[_verifyButton setEnabled:NO];
self.navigationItem.rightBarButtonItem = _verifyButton;
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
NSIndexPath* CVCIndexPath =
[self.collectionViewModel indexPathForItem:_CVCItem
inSectionWithIdentifier:SectionIdentifierMain];
CVCCell* CVC = base::mac::ObjCCastStrict<CVCCell>(
[self.collectionView cellForItemAtIndexPath:CVCIndexPath]);
[self focusInputIfNeeded:CVC];
}
#pragma mark - CollectionViewController
- (void)loadModel {
[super loadModel];
CollectionViewModel* model = self.collectionViewModel;
[model addSectionWithIdentifier:SectionIdentifierMain];
autofill::CardUnmaskPromptController* controller = _bridge->GetController();
NSString* instructions =
SysUTF16ToNSString(controller->GetInstructionsMessage());
int CVCImageResourceID = controller->GetCvcImageRid();
_CVCItem = [[CVCItem alloc] initWithType:ItemTypeCVC];
_CVCItem.instructionsText = instructions;
_CVCItem.CVCImageResourceID = CVCImageResourceID;
[model addItem:_CVCItem toSectionWithIdentifier:SectionIdentifierMain];
if (controller->CanStoreLocally()) {
_storageSwitchItem =
[[StorageSwitchItem alloc] initWithType:ItemTypeStorageSwitch];
_storageSwitchItem.on = controller->GetStoreLocallyStartState();
[model addItem:_storageSwitchItem
toSectionWithIdentifier:SectionIdentifierMain];
_storageSwitchTooltip = [[StorageSwitchTooltip alloc] init];
[_storageSwitchTooltip setHidden:YES];
[self.collectionView addSubview:_storageSwitchTooltip];
} else {
_storageSwitchItem = nil;
}
// No status item when loading the model.
_statusItem = nil;
}
#pragma mark - Private
- (void)showCVCInputForm {
[self showCVCInputFormWithError:nil];
}
- (void)showCVCInputFormWithError:(NSString*)errorMessage {
[_verifyButton setEnabled:NO];
[self loadModel];
_CVCItem.errorMessage = errorMessage;
// If the server requested a new expiration date, show the date input. If it
// didn't and there was an error, show the "New card?" link which will show
// the date inputs on click. This link is intended to remind the user that
// they might have recently received a new card with updated expiration date
// and CVC. At the same time, we only put the CVC input in an error state if
// we're not requesting a new date. Because if we're asking the user for both,
// we don't know which is incorrect.
if (_bridge->GetController()->ShouldRequestExpirationDate()) {
_CVCItem.showDateInput = YES;
} else if (errorMessage) {
_CVCItem.showNewCardButton = YES;
_CVCItem.showCVCInputError = YES;
}
}
- (void)showSpinner {
[_verifyButton setEnabled:NO];
[_storageSwitchTooltip setHidden:YES];
[self
updateWithStatus:StatusItemState::VERIFYING
text:l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_VERIFICATION_IN_PROGRESS)];
}
- (void)showSuccess {
[_verifyButton setEnabled:NO];
[self updateWithStatus:StatusItemState::VERIFIED
text:l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_VERIFICATION_SUCCESS)];
}
- (void)showError:(NSString*)errorMessage {
[_cancelButton setTitle:l10n_util::GetNSString(IDS_CLOSE)];
[_verifyButton setEnabled:NO];
[self updateWithStatus:StatusItemState::ERROR text:errorMessage];
}
- (void)updateWithStatus:(StatusItemState)state text:(NSString*)text {
if (!_statusItem) {
_statusItem = [[StatusItem alloc] initWithType:ItemTypeStatus];
_statusItem.text = text;
_statusItem.state = state;
// Remove all the present items to replace them with the status item.
[self.collectionViewModel
removeSectionWithIdentifier:SectionIdentifierMain];
[self.collectionViewModel addSectionWithIdentifier:SectionIdentifierMain];
[self.collectionViewModel addItem:_statusItem
toSectionWithIdentifier:SectionIdentifierMain];
[self.collectionView reloadData];
} else {
_statusItem.text = text;
_statusItem.state = state;
[self reconfigureCellsForItems:@[ _statusItem ]
inSectionWithIdentifier:SectionIdentifierMain];
[self.collectionViewLayout invalidateLayout];
}
}
- (CGFloat)statusCellHeight {
const CGFloat collectionViewWidth =
CGRectGetWidth(self.collectionView.bounds);
// The status cell replaces the previous content of the collection. So it is
// sized based on what appears when not loading.
const CGFloat preferredHeightForCVC =
[MDCCollectionViewCell cr_preferredHeightForWidth:collectionViewWidth
forItem:_CVCItem];
CGFloat preferredHeightForStorageSwitch = 0;
if (_storageSwitchItem) {
preferredHeightForStorageSwitch =
[MDCCollectionViewCell cr_preferredHeightForWidth:collectionViewWidth
forItem:_storageSwitchItem];
}
const CGFloat preferredHeightForStatus =
[MDCCollectionViewCell cr_preferredHeightForWidth:collectionViewWidth
forItem:_statusItem];
// Return the size of the replaced content, but make sure it is at least the
// minimal status cell height.
return MAX(preferredHeightForCVC + preferredHeightForStorageSwitch,
preferredHeightForStatus);
}
- (void)layoutTooltipFromButton:(UIButton*)button {
const CGRect buttonFrameInCollectionView =
[self.collectionView convertRect:button.bounds fromView:button];
CGRect tooltipFrame = _storageSwitchTooltip.frame;
// First, set the width and use sizeToFit to have the label flow the text and
// set the height appropriately.
const CGFloat kTooltipMargin = 8;
CGFloat availableWidth =
CGRectGetMinX(buttonFrameInCollectionView) - 2 * kTooltipMargin;
const CGFloat kMaxTooltipWidth = 210;
tooltipFrame.size.width = MIN(availableWidth, kMaxTooltipWidth);
_storageSwitchTooltip.frame = tooltipFrame;
[_storageSwitchTooltip sizeToFit];
// Then use the size to position the tooltip appropriately, based on the
// button position.
tooltipFrame = _storageSwitchTooltip.frame;
tooltipFrame.origin.x = CGRectGetMinX(buttonFrameInCollectionView) -
kTooltipMargin - CGRectGetWidth(tooltipFrame);
tooltipFrame.origin.y = CGRectGetMaxY(buttonFrameInCollectionView) -
CGRectGetHeight(tooltipFrame);
_storageSwitchTooltip.frame = tooltipFrame;
}
- (BOOL)inputCVCIsValid:(CVCItem*)item {
return _bridge->GetController()->InputCvcIsValid(
base::SysNSStringToUTF16(item.CVCText));
}
- (BOOL)inputExpirationIsValid:(CVCItem*)item {
if (!item.showDateInput) {
return YES;
}
return _bridge->GetController()->InputExpirationIsValid(
base::SysNSStringToUTF16(item.monthText),
base::SysNSStringToUTF16(item.yearText));
}
- (void)inputsDidChange:(CVCItem*)item {
[_verifyButton setEnabled:[self inputCVCIsValid:item] &&
[self inputExpirationIsValid:item]];
}
- (void)updateDateErrorState:(CVCItem*)item {
// Only change the error state if the inputs are of a length that can be
// interpreted as valid or not.
NSUInteger monthTextLength = item.monthText.length;
if (monthTextLength != 1 && monthTextLength != 2) {
return;
}
NSUInteger yearTextLength = item.yearText.length;
if (yearTextLength != 2 && yearTextLength != 4) {
return;
}
if ([self inputExpirationIsValid:item]) {
item.showDateInputError = NO;
item.errorMessage = @"";
} else {
item.showDateInputError = NO;
item.errorMessage = l10n_util::GetNSString(
IDS_AUTOFILL_CARD_UNMASK_INVALID_EXPIRATION_DATE);
}
[self reconfigureCellsForItems:@[ item ]
inSectionWithIdentifier:SectionIdentifierMain];
[self.collectionViewLayout invalidateLayout];
}
- (void)focusInputIfNeeded:(CVCCell*)CVC {
// Focus the first visible input, unless the orientation is landscape. In
// landscape, the keyboard covers up the storage checkbox shown below this
// view and the user might never see it.
if (UIInterfaceOrientationIsPortrait(
[UIApplication sharedApplication].statusBarOrientation)) {
// Also check whether any of the inputs are already the first responder and
// are non-empty, in which case the focus should be left there.
if ((!CVC.monthInput.isFirstResponder || CVC.monthInput.text.length == 0) &&
(!CVC.yearInput.isFirstResponder || CVC.yearInput.text.length == 0) &&
(!CVC.CVCInput.isFirstResponder || CVC.CVCInput.text.length == 0)) {
if (_CVCItem.showDateInput) {
[CVC.monthInput becomeFirstResponder];
} else {
[CVC.CVCInput becomeFirstResponder];
}
}
}
}
#pragma mark - Actions
- (void)onVerify:(id)sender {
autofill::CardUnmaskPromptController* controller = _bridge->GetController();
DCHECK(controller);
// The controller requires a 4-digit year. Convert if necessary.
NSString* yearText = _CVCItem.yearText;
if (yearText.length == 2) {
NSInteger inputYear = yearText.integerValue;
NSInteger currentYear =
[[NSCalendar currentCalendar] components:NSCalendarUnitYear
fromDate:[NSDate date]]
.year;
inputYear += currentYear - (currentYear % 100);
yearText = [@(inputYear) stringValue];
}
controller->OnUnmaskResponse(base::SysNSStringToUTF16(_CVCItem.CVCText),
base::SysNSStringToUTF16(_CVCItem.monthText),
base::SysNSStringToUTF16(yearText),
_storageSwitchItem.on);
}
- (void)onCancel:(id)sender {
_bridge->PerformClose();
}
- (void)onTooltipButtonTapped:(UIButton*)button {
BOOL shouldShowTooltip = !button.selected;
button.highlighted = shouldShowTooltip;
if (shouldShowTooltip) {
button.selected = YES;
[self layoutTooltipFromButton:button];
[_storageSwitchTooltip setHidden:NO];
} else {
button.selected = NO;
[_storageSwitchTooltip setHidden:YES];
}
}
- (void)onStorageSwitchChanged:(UISwitch*)switchView {
// Update the item.
_storageSwitchItem.on = switchView.on;
}
- (void)onNewCardLinkTapped:(UIButton*)button {
_bridge->GetController()->NewCardLinkClicked();
_CVCItem.instructionsText =
SysUTF16ToNSString(_bridge->GetController()->GetInstructionsMessage());
_CVCItem.monthText = @"";
_CVCItem.yearText = @"";
_CVCItem.CVCText = @"";
_CVCItem.errorMessage = @"";
_CVCItem.showDateInput = YES;
_CVCItem.showNewCardButton = NO;
_CVCItem.showDateInputError = NO;
_CVCItem.showCVCInputError = NO;
[self reconfigureCellsForItems:@[ _CVCItem ]
inSectionWithIdentifier:SectionIdentifierMain];
[self.collectionViewLayout invalidateLayout];
[self inputsDidChange:_CVCItem];
}
#pragma mark - UITextField Events
- (void)monthInputDidChange:(UITextField*)textField {
_CVCItem.monthText = textField.text;
[self inputsDidChange:_CVCItem];
[self updateDateErrorState:_CVCItem];
}
- (void)yearInputDidChange:(UITextField*)textField {
_CVCItem.yearText = textField.text;
[self inputsDidChange:_CVCItem];
[self updateDateErrorState:_CVCItem];
}
- (void)CVCInputDidChange:(UITextField*)textField {
_CVCItem.CVCText = textField.text;
[self inputsDidChange:_CVCItem];
if (_bridge->GetController()->InputCvcIsValid(
base::SysNSStringToUTF16(textField.text))) {
_CVCItem.showCVCInputError = NO;
[self updateDateErrorState:_CVCItem];
}
}
#pragma mark - MDCCollectionViewStylingDelegate
- (CGFloat)collectionView:(UICollectionView*)collectionView
cellHeightAtIndexPath:(NSIndexPath*)indexPath {
CollectionViewItem* item =
[self.collectionViewModel itemAtIndexPath:indexPath];
if (item.type == ItemTypeStatus) {
return [self statusCellHeight];
}
return [MDCCollectionViewCell
cr_preferredHeightForWidth:CGRectGetWidth(collectionView.bounds)
forItem:item];
}
- (BOOL)collectionView:(UICollectionView*)collectionView
hidesInkViewAtIndexPath:(NSIndexPath*)indexPath {
return YES;
}
#pragma mark - UICollectionViewDataSource
- (UICollectionViewCell*)collectionView:(UICollectionView*)collectionView
cellForItemAtIndexPath:(NSIndexPath*)indexPath {
UICollectionViewCell* cell =
[super collectionView:collectionView cellForItemAtIndexPath:indexPath];
ItemType itemType = static_cast<ItemType>(
[self.collectionViewModel itemTypeForIndexPath:indexPath]);
switch (itemType) {
case ItemTypeCVC: {
CVCCell* cellForCVC = base::mac::ObjCCastStrict<CVCCell>(cell);
[cellForCVC.monthInput addTarget:self
action:@selector(monthInputDidChange:)
forControlEvents:UIControlEventEditingChanged];
[cellForCVC.yearInput addTarget:self
action:@selector(yearInputDidChange:)
forControlEvents:UIControlEventEditingChanged];
[cellForCVC.CVCInput addTarget:self
action:@selector(CVCInputDidChange:)
forControlEvents:UIControlEventEditingChanged];
[cellForCVC.buttonForNewCard addTarget:self
action:@selector(onNewCardLinkTapped:)
forControlEvents:UIControlEventTouchUpInside];
break;
}
case ItemTypeStorageSwitch: {
StorageSwitchCell* storageSwitchCell =
base::mac::ObjCCastStrict<StorageSwitchCell>(cell);
[storageSwitchCell.tooltipButton
addTarget:self
action:@selector(onTooltipButtonTapped:)
forControlEvents:UIControlEventTouchUpInside];
[storageSwitchCell.switchView addTarget:self
action:@selector(onStorageSwitchChanged:)
forControlEvents:UIControlEventValueChanged];
break;
}
default:
break;
}
return cell;
}
#pragma mark - UICollectionViewDelegate
- (void)collectionView:(UICollectionView*)collectionView
willDisplayCell:(UICollectionViewCell*)cell
forItemAtIndexPath:(NSIndexPath*)indexPath {
CVCCell* CVC = base::mac::ObjCCast<CVCCell>(cell);
if (CVC) {
[self focusInputIfNeeded:CVC];
}
}
@end