chromium/ios/chrome/browser/autofill/ui_bundled/form_input_accessory/form_suggestion_label.mm

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/autofill/ui_bundled/form_input_accessory/form_suggestion_label.h"

#import <QuartzCore/QuartzCore.h>
#import <stddef.h>
#import <cmath>

#import "base/strings/string_number_conversions.h"
#import "base/strings/sys_string_conversions.h"
#import "components/autofill/core/browser/autofill_data_util.h"
#import "components/autofill/core/browser/data_model/credit_card.h"
#import "components/autofill/ios/browser/form_suggestion.h"
#import "components/password_manager/ios/shared_password_controller.h"
#import "ios/chrome/browser/autofill/model/form_suggestion_constants.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/grit/ios_strings.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"

namespace {

// Font size of button titles.
constexpr CGFloat kIpadFontSize = 15;
constexpr CGFloat kIphoneFontSize = 14;

// The horizontal space between the edge of the background and the text.
constexpr CGFloat kBorderWidth = 14;
// The horizontal space between the edge of the background and the text for the
// large keyboard accessory.
constexpr CGFloat kSmallBorderWidth = 12;
// The space between items in the label.
constexpr CGFloat kSpacing = 4;
// The corner radius of the label.
constexpr CGFloat kCornerRadius = 8;

// The size adjustment for the subtitle font from the default font size.
constexpr CGFloat kSubtitleFontPointSizeAdjustment = -1;
// The size adjustment for the title font from the default font size.
constexpr CGFloat kTitleFontPointSizeAdjustment = 1;
// The extra space between the title label and the subtitle.
constexpr CGFloat kVerticalSpacing = 2;

// Shadow parameters.
constexpr CGFloat kShadowRadius = 0.5;
constexpr CGFloat kShadowVerticalOffset = 1.0;
constexpr CGFloat kShadowOpacity = 1.0;

// The preferred minimum width of the icon shown on the label.
constexpr CGFloat kSuggestionIconWidth = 40;

// Offset required to see half of the icon of the 2nd credit card suggestion
// when the first credit card suggestion is at maximum width. This number
// represents the width of the stack view minus the width of the first
// suggestion.
constexpr CGFloat kHalfCreditCardIconOffset =
    2 * kSmallBorderWidth + 2 * kSpacing + 0.5 * kSuggestionIconWidth;

// Returns the font for the title line of the suggestion.
UIFont* TitleFont(CGFloat fontSize) {
  return [UIFont systemFontOfSize:fontSize + kTitleFontPointSizeAdjustment
                           weight:UIFontWeightMedium];
}

// Returns the font for the subtitle line of the suggestion.
UIFont* SubtitleFont(CGFloat fontSize) {
  UIFont* font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1];
  return [font fontWithSize:fontSize + kSubtitleFontPointSizeAdjustment];
}

// Creates a label with the given `text` and `alpha` suitable for use in a
// suggestion button in the keyboard accessory view.
UILabel* TextLabel(NSString* text,
                   UIColor* textColor,
                   BOOL bold,
                   BOOL isTitle) {
  UILabel* label = [[UILabel alloc] init];
  [label setText:text];
  CGFloat fontSize =
      (ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET)
          ? kIpadFontSize
          : kIphoneFontSize;
  UIFont* font;
  if (IsKeyboardAccessoryUpgradeEnabled()) {
    font = isTitle ? TitleFont(fontSize) : SubtitleFont(fontSize);
  } else {
    font = bold ? [UIFont boldSystemFontOfSize:fontSize]
                : [UIFont systemFontOfSize:fontSize];
  }
  [label setFont:font];
  label.textColor = textColor;
  [label setBackgroundColor:[UIColor clearColor]];
  return label;
}

}  // namespace

@implementation FormSuggestionLabel {
  // Client of this view.
  __weak id<FormSuggestionLabelDelegate> _delegate;
  FormSuggestion* _suggestion;
}

#pragma mark - Public

- (id)initWithSuggestion:(FormSuggestion*)suggestion
                    index:(NSUInteger)index
           numSuggestions:(NSUInteger)numSuggestions
    accessoryTrailingView:(UIView*)accessoryTrailingView
                 delegate:(id<FormSuggestionLabelDelegate>)delegate {
  self = [super initWithFrame:CGRectZero];
  if (self) {
    _suggestion = suggestion;
    _delegate = delegate;

    UIStackView* stackView = [[UIStackView alloc] initWithArrangedSubviews:@[]];
    stackView.axis = UILayoutConstraintAxisHorizontal;
    stackView.alignment = UIStackViewAlignmentCenter;
    stackView.layoutMarginsRelativeArrangement = YES;
    stackView.layoutMargins =
        UIEdgeInsetsMake(0, [self borderWidth], 0, [self borderWidth]);
    stackView.spacing = kSpacing;
    stackView.translatesAutoresizingMaskIntoConstraints = NO;
    [self addSubview:stackView];
    AddSameConstraints(stackView, self);

    if (suggestion.icon) {
      UIImageView* iconView = [[UIImageView alloc]
          initWithImage:[self resizeIconIfNecessary:suggestion.icon]];
      // If we have an icon, we want to see the icon and let the text be
      // truncated rather than expanding the text area and hiding the icon.
      [iconView
          setContentCompressionResistancePriority:UILayoutPriorityRequired
                                          forAxis:
                                              UILayoutConstraintAxisHorizontal];
      [stackView addArrangedSubview:iconView];
    }

    NSString* suggestionText = suggestion.value;
    if (IsKeyboardAccessoryUpgradeEnabled()) {
      UIStackView* verticalStackView =
          [[UIStackView alloc] initWithArrangedSubviews:@[]];
      verticalStackView.axis = UILayoutConstraintAxisVertical;
      verticalStackView.alignment = UIStackViewAlignmentLeading;
      verticalStackView.layoutMarginsRelativeArrangement = YES;
      verticalStackView.layoutMargins =
          UIEdgeInsetsMake(0, suggestion.icon ? kSpacing : 0, 0, 0);
      verticalStackView.spacing = kVerticalSpacing;
      [stackView addArrangedSubview:verticalStackView];

      // Insert the next subviews vertically instead of horizonatally.
      stackView = verticalStackView;

      if ([suggestionText hasSuffix:kPasswordFormSuggestionSuffix]) {
        suggestionText = [suggestionText
            substringToIndex:suggestionText.length -
                             kPasswordFormSuggestionSuffix.length];
        if ([suggestionText length] == 0) {
          suggestionText =
              l10n_util::GetNSString(IDS_IOS_AUTOFILL_PASSWORD_NO_USERNAME);
        }
      }
    }

    UILabel* valueLabel =
        TextLabel(suggestionText, [UIColor colorNamed:kTextPrimaryColor],
                  /*bold=*/YES, /*isTitle=*/YES);
    [stackView addArrangedSubview:[self splitLabel:valueLabel]];

    if ([suggestion.minorValue length] > 0) {
      UILabel* minorValueLabel = TextLabel(
          suggestion.minorValue, [UIColor colorNamed:kTextPrimaryColor],
          /*bold=*/YES, /*isTitle=*/NO);
      [stackView addArrangedSubview:[self splitLabel:minorValueLabel]];
    }

    if ([suggestion.displayDescription length] > 0) {
      UILabel* description = TextLabel(suggestion.displayDescription,
                                       [UIColor colorNamed:kTextSecondaryColor],
                                       /*bold=*/NO, /*isTitle=*/NO);
      [stackView addArrangedSubview:[self splitLabel:description]];
    }

    [self setBackgroundColor:[self customBackgroundColor]];

    [self setClipsToBounds:YES];
    [self setUserInteractionEnabled:YES];
    [self setIsAccessibilityElement:YES];
    [self setAccessibilityLabel:l10n_util::GetNSStringF(
                                    IDS_IOS_AUTOFILL_ACCNAME_SUGGESTION,
                                    base::SysNSStringToUTF16(suggestionText),
                                    base::SysNSStringToUTF16(
                                        suggestion.displayDescription))];
    [self setAccessibilityValue:l10n_util::GetNSStringF(
                                    IDS_IOS_AUTOFILL_SUGGESTION_INDEX_VALUE,
                                    base::NumberToString16(index + 1),
                                    base::NumberToString16(numSuggestions))];
    [self
        setAccessibilityIdentifier:kFormSuggestionLabelAccessibilityIdentifier];

    if (IsKeyboardAccessoryUpgradeEnabled()) {
      CGFloat maximumWidth = [self maximumWidth:accessoryTrailingView];
      if (maximumWidth < CGFLOAT_MAX) {
        [self.widthAnchor constraintLessThanOrEqualToConstant:maximumWidth]
            .active = YES;
      }
    }
  }

  return self;
}

- (void)layoutSubviews {
  [super layoutSubviews];
  self.layer.cornerRadius = [self cornerRadius];
  if (IsKeyboardAccessoryUpgradeEnabled()) {
    self.layer.shadowRadius = kShadowRadius;
    self.layer.shadowOffset = CGSizeMake(0, kShadowVerticalOffset);
    self.layer.shadowOpacity = kShadowOpacity;
    self.layer.shadowColor =
        [UIColor colorNamed:kBackgroundShadowColor].CGColor;
    self.layer.masksToBounds = NO;
  }
}

#pragma mark - UIResponder

- (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [self setBackgroundColor:[UIColor colorNamed:kGrey300Color]];
}

- (void)touchesMoved:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  // No op. This method implementation is needed per the UIResponder docs.
}

- (void)touchesCancelled:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [self setBackgroundColor:[self customBackgroundColor]];
}

- (void)touchesEnded:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
  [self setBackgroundColor:[self customBackgroundColor]];

  // Don't count touches ending outside the view as as taps.
  CGPoint locationInView = [touches.anyObject locationInView:self];
  if (CGRectContainsPoint(self.bounds, locationInView)) {
    [_delegate didTapFormSuggestionLabel:self];
  }
}

#pragma mark - Private

// Color of the suggestion chips.
- (UIColor*)customBackgroundColor {
  return
      [UIColor colorNamed:IsKeyboardAccessoryUpgradeEnabled() ? kBackgroundColor
                                                              : kGrey100Color];
}

// Corner radius of the suggestion chips.
- (CGFloat)cornerRadius {
  return IsKeyboardAccessoryUpgradeEnabled() ? kCornerRadius
                                             : self.bounds.size.height / 2.0;
}

- (CGFloat)borderWidth {
  return IsKeyboardAccessoryUpgradeEnabled() ? kSmallBorderWidth : kBorderWidth;
}

// Returns whether this label is for a credit card suggestion.
- (BOOL)isCreditCardSuggestion {
  return (_suggestion.type == autofill::SuggestionType::kCreditCardEntry) ||
         (_suggestion.type ==
          autofill::SuggestionType::kVirtualCreditCardEntry);
}

// Resize the icon if it's a credit card icon which requires an upscaling.
- (UIImage*)resizeIconIfNecessary:(UIImage*)icon {
  if (IsKeyboardAccessoryUpgradeEnabled() && [self isCreditCardSuggestion] &&
      icon && icon.size.width > 0 && icon.size.width < kSuggestionIconWidth) {
    // For a simple image resize, we can keep the same underlying image
    // and only adjust the ratio.
    CGFloat ratio = icon.size.width / kSuggestionIconWidth;
    icon = [UIImage imageWithCGImage:[icon CGImage]
                               scale:icon.scale * ratio
                         orientation:icon.imageOrientation];
  }
  return icon;
}

// Computes the suggestion label's maximum width.
// Returns CGFLOAT_MAX if there's no maximum width.
- (CGFloat)maximumWidth:(UIView*)accessoryTrailingView {
  CGFloat maxWidth = CGFLOAT_MAX;
  // We're using the screen width because the 'window' member is nil at the
  // moment of setting up the label's width anchor.
  CGSize windowSize = [[UIScreen mainScreen] bounds].size;
  CGFloat portraitScreenWidth = MIN(windowSize.width, windowSize.height);
  switch (_suggestion.type) {
    case autofill::SuggestionType::kCreditCardEntry:
    case autofill::SuggestionType::kVirtualCreditCardEntry: {
      // Max width is just enough to show half of the credit card icon on the
      // 2nd suggestion, in portrait mode.
      CGFloat staticButtonsWidth = accessoryTrailingView.frame.size.width;
      maxWidth = (portraitScreenWidth - staticButtonsWidth) -
                 kHalfCreditCardIconOffset;
    } break;
    case autofill::SuggestionType::kAddressEntry:
      // Max width is half width, in portrait mode.
      maxWidth = portraitScreenWidth * 0.5;
      break;
    default:
      break;
  }
  return maxWidth;
}

// Splits a credit card label into 2 labels, with one being an incompressible
// credit card number label.
- (UIView*)splitLabel:(UILabel*)label {
  if (![self isCreditCardSuggestion]) {
    return label;
  }

  // Look for a credit card number in the string. Note that U+202A is the
  // "Left-to-right embedding" character and U+202C is the "Pop directional
  // formatting" character. Credit card numbers are surrounded by these two
  // Unicode characters.
  NSRange range =
      [label.text rangeOfString:@"\U0000202a•⁠ ⁠•⁠ ⁠"];
  if (range.location == NSNotFound || range.location < 1) {
    return label;
  }

  // Split the string in pre and post credit card number labels.
  UILabel* creditCardLabel = [[UILabel alloc] init];
  creditCardLabel.font = label.font;
  creditCardLabel.text = [label.text substringFromIndex:range.location];
  creditCardLabel.textColor = label.textColor;
  // The credit card number should not be compressible.
  [creditCardLabel
      setContentCompressionResistancePriority:UILayoutPriorityRequired
                                      forAxis:UILayoutConstraintAxisHorizontal];

  // Remove credit card number from the original string.
  label.text = [label.text substringToIndex:range.location - 1];

  // Stack both labels horizontally.
  UIStackView* stackView = [[UIStackView alloc] initWithArrangedSubviews:@[]];
  stackView.axis = UILayoutConstraintAxisHorizontal;
  stackView.alignment = UIStackViewAlignmentCenter;
  stackView.spacing = kSpacing;
  [stackView addArrangedSubview:label];
  [stackView addArrangedSubview:creditCardLabel];
  return stackView;
}

@end