chromium/ios/chrome/browser/sad_tab/ui_bundled/sad_tab_view.mm

// Copyright 2015 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/sad_tab/ui_bundled/sad_tab_view.h"

#import "base/metrics/histogram_macros.h"
#import "base/strings/sys_string_conversions.h"
#import "components/grit/components_scaled_resources.h"
#import "components/strings/grit/components_strings.h"
#import "components/ui_metrics/sadtab_metrics_types.h"
#import "ios/chrome/browser/shared/model/url/chrome_url_constants.h"
#import "ios/chrome/browser/shared/public/commands/application_commands.h"
#import "ios/chrome/browser/shared/ui/util/rtl_geometry.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/pointer_interaction_util.h"
#import "ios/chrome/common/ui/util/text_view_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ios/web/public/browser_state.h"
#import "ios/web/public/navigation/navigation_manager.h"
#import "net/base/apple/url_conversions.h"
#import "ui/base/device_form_factor.h"
#import "ui/base/l10n/l10n_util.h"
#import "url/gurl.h"

namespace {
// Layout constants.
const UIEdgeInsets kLayoutInsets = {24.0f, 24.0f, 24.0f, 24.0f};
const CGFloat kLayoutBoundsMaxWidth = 600.0f;
const CGFloat kContainerViewLandscapeTopPadding = 22.0f;
const CGFloat kTitleLabelTopPadding = 26.0f;
const CGFloat kMessageTextViewTopPadding = 16.0f;
const CGFloat kFooterLabelTopPadding = 16.0f;
const CGFloat kActionButtonHeight = 48.0f;
const CGFloat kActionButtonTopPadding = 16.0f;
// Label font sizes.
const CGFloat kTitleLabelFontSize = 23.0f;
const CGFloat kMessageTextViewFontSize = 14.0f;
const CGFloat kActionButtonFontSize = 14.0f;
const CGFloat kFooterLabelFontSize = 14.0f;
// Feedback message bullet indentation.
const CGFloat kBulletIndent = 17.0f;        // Left margin to bullet indent.
const CGFloat kBulletedTextIndent = 15.0f;  // Bullet to text indent.
// Format for bulleted line (<tab><bullet><tab><string>).
NSString* const kMessageTextViewBulletPrefix = @"\t\u2022\t";
// Separator for each new bullet line.
NSString* const kMessageTextViewBulletSuffix = @"\n";
// "<RTL Begin Indicator><NSString Token><RTL End Indicator>".
NSString* const kMessageTextViewBulletRTLFormat = @"\u202E%@\u202C";
}  // namespace

@interface SadTabView () <UITextViewDelegate> {
  UITextView* _messageTextView;
  UIButton* _actionButton;
}

// YES if the SadTab UI is displayed in Off The Record browsing mode.
@property(nonatomic, readonly, getter=isOffTheRecord) BOOL offTheRecord;
// Container view that displays all other subviews.
@property(nonatomic, readonly, strong) UIView* containerView;
// Displays the Sad Tab face.
@property(nonatomic, readonly, strong) UIImageView* imageView;
// Displays the Sad Tab title.
@property(nonatomic, readonly, strong) UILabel* titleLabel;
// Displays the Sad Tab footer message (including a link to more help).
@property(nonatomic, readonly, strong) UITextView* footerLabel;
// The bounds of `containerView`, with a height updated to CGFLOAT_MAX to allow
// text to be laid out using as many lines as necessary.
@property(nonatomic, readonly) CGRect containerBounds;

// Subview layout methods.  Must be called in the following order, as subsequent
// layouts reference the values set in previous functions.
- (void)layoutImageView;
- (void)layoutTitleLabel;
- (void)layoutMessageTextView;
- (void)layoutFooterLabel;
- (void)layoutActionButton;
- (void)layoutContainerView;

// Takes an array of strings and bulletizes them into a single multi-line string
// for display. The string has NSParagraphStyle attributes for tab alignment.
+ (nonnull NSAttributedString*)bulletedAttributedStringFromStrings:
    (nonnull NSArray<NSString*>*)strings;

// Returns the appropriate title for the view, e.g. 'Aw Snap!'.
- (nonnull NSString*)titleLabelText;
// Returns the appropriate message text body for the view, this will typically
// be a larger body of explanation or help text. Returns an attributed string
// to allow for text formatting and layout to be applied to the returned string.
- (nonnull NSAttributedString*)messageTextViewAttributedText;
// Returns the full footer string containing a link, intended to be the last
// piece of text.
- (nonnull NSString*)footerLabelText;
// Returns the substring of the footer string which is to be the underlined link
// text. (May be the entire footer label string).
- (nonnull NSString*)footerLinkText;
// Returns the string to be used for the main action button.
- (nonnull NSString*)buttonText;

// The action selector for `_actionButton`.
- (void)handleActionButtonTapped;

// Returns the desired background color.
+ (UIColor*)sadTabBackgroundColor;

@end

#pragma mark - SadTabView

@implementation SadTabView

@synthesize imageView = _imageView;
@synthesize containerView = _containerView;
@synthesize titleLabel = _titleLabel;
@synthesize footerLabel = _footerLabel;

- (instancetype)initWithMode:(SadTabViewMode)mode
                offTheRecord:(BOOL)offTheRecord {
  self = [super initWithFrame:CGRectZero];
  if (self) {
    _mode = mode;
    _offTheRecord = offTheRecord;
    self.backgroundColor = [[self class] sadTabBackgroundColor];
  }
  return self;
}

#pragma mark - Text Utilities

+ (nonnull NSAttributedString*)bulletedAttributedStringFromStrings:
    (nonnull NSArray<NSString*>*)strings {
  // Ensures the bullet string is appropriately directional.
  NSString* directionalBulletPrefix =
      base::i18n::IsRTL()
          ? [NSString stringWithFormat:kMessageTextViewBulletRTLFormat,
                                       kMessageTextViewBulletPrefix]
          : kMessageTextViewBulletPrefix;

  // Assemble the strings into a single string with each line preceded by a
  // bullet point.
  NSMutableString* bulletedString = [[NSMutableString alloc] init];
  for (NSString* string in strings) {
    // If content line has been added to the bulletedString already, ensure the
    // suffix is applied, otherwise don't (e.g. don't for the first item).
    NSArray* newStringArray =
        bulletedString.length
            ? @[ kMessageTextViewBulletSuffix, directionalBulletPrefix, string ]
            : @[ directionalBulletPrefix, string ];
    [bulletedString appendString:[newStringArray componentsJoinedByString:@""]];
  }

  // Prepare a paragraph style that will allow for the alignment of lines of
  // text separately to the alignment of the bullet-points.
  NSMutableParagraphStyle* paragraphStyle =
      [[NSParagraphStyle defaultParagraphStyle] mutableCopy];
  paragraphStyle.tabStops = @[
    [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentNatural
                                    location:kBulletIndent
                                     options:@{}],
    [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentNatural
                                    location:kBulletIndent + kBulletedTextIndent
                                     options:@{}]
  ];
  paragraphStyle.firstLineHeadIndent = 0.0f;
  paragraphStyle.headIndent = kBulletIndent + kBulletedTextIndent;

  // Use the paragraph style on the full string.
  NSAttributedString* bulletedAttributedString = [[NSAttributedString alloc]
      initWithString:bulletedString
          attributes:@{NSParagraphStyleAttributeName : paragraphStyle}];

  DCHECK(bulletedAttributedString);
  return bulletedAttributedString;
}

#pragma mark - Label Text

- (nonnull NSString*)titleLabelText {
  NSString* label = nil;
  switch (self.mode) {
    case SadTabViewMode::RELOAD:
      label = l10n_util::GetNSString(IDS_SAD_TAB_TITLE);
      break;
    case SadTabViewMode::FEEDBACK:
      label = l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_TITLE);
      break;
  }
  DCHECK(label);
  return label;
}

- (nonnull NSAttributedString*)messageTextViewAttributedText {
  NSAttributedString* label = nil;
  switch (self.mode) {
    case SadTabViewMode::RELOAD:
      label = [[NSAttributedString alloc]
          initWithString:l10n_util::GetNSString(IDS_SAD_TAB_MESSAGE)];
      break;
    case SadTabViewMode::FEEDBACK: {
      NSString* feedbackIntroductionString = [NSString
          stringWithFormat:@"%@\n\n",
                           l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_TRY)];
      NSMutableAttributedString* feedbackString =
          [[NSMutableAttributedString alloc]
              initWithString:feedbackIntroductionString];

      NSMutableArray* stringsArray = [NSMutableArray
          arrayWithObjects:l10n_util::GetNSString(
                               IDS_SAD_TAB_RELOAD_RESTART_BROWSER),
                           l10n_util::GetNSString(
                               IDS_SAD_TAB_RELOAD_RESTART_DEVICE),
                           nil];
      if (!self.offTheRecord) {
        NSString* incognitoSuggestionString =
            l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_INCOGNITO);
        [stringsArray insertObject:incognitoSuggestionString atIndex:0];
      }

      NSAttributedString* bulletedListString =
          [[self class] bulletedAttributedStringFromStrings:stringsArray];
      [feedbackString appendAttributedString:bulletedListString];
      label = feedbackString;
      break;
    }
  }
  DCHECK(label);
  return label;
}

- (nonnull NSString*)footerLabelText {
  NSString* label = nil;
  switch (self.mode) {
    case SadTabViewMode::RELOAD: {
      std::u16string footerLinkText(
          l10n_util::GetStringUTF16(IDS_SAD_TAB_HELP_LINK));
      label = base::SysUTF16ToNSString(
          l10n_util::GetStringFUTF16(IDS_SAD_TAB_HELP_MESSAGE, footerLinkText));
    } break;
    case SadTabViewMode::FEEDBACK:
      label = l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_LEARN_MORE);
      break;
  }
  DCHECK(label);
  return label;
}

- (nonnull NSString*)footerLinkText {
  NSString* label = nil;
  switch (self.mode) {
    case SadTabViewMode::RELOAD: {
      std::u16string footerLinkText(
          l10n_util::GetStringUTF16(IDS_SAD_TAB_HELP_LINK));
      label = base::SysUTF16ToNSString(footerLinkText);
    } break;
    case SadTabViewMode::FEEDBACK:
      label = l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_LEARN_MORE);
      break;
  }
  DCHECK(label);
  return label;
}

- (nonnull NSString*)buttonText {
  NSString* label = nil;
  switch (self.mode) {
    case SadTabViewMode::RELOAD:
      label = l10n_util::GetNSString(IDS_SAD_TAB_RELOAD_LABEL);
      break;
    case SadTabViewMode::FEEDBACK:
      label = l10n_util::GetNSString(IDS_SAD_TAB_SEND_FEEDBACK_LABEL);
      break;
  }
  DCHECK(label);
  return label;
}

#pragma mark Accessors

- (UIView*)containerView {
  if (!_containerView) {
    _containerView = [[UIView alloc] initWithFrame:CGRectZero];
    [_containerView setBackgroundColor:self.backgroundColor];
  }
  return _containerView;
}

- (UIImageView*)imageView {
  if (!_imageView) {
    UIImage* sadTabImage = [NativeImage(IDR_CRASH_SAD_TAB)
        imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
    _imageView = [[UIImageView alloc] initWithImage:sadTabImage];
    _imageView.tintColor = [UIColor colorNamed:kTextSecondaryColor];
    [_imageView setBackgroundColor:self.backgroundColor];
  }
  return _imageView;
}

- (UILabel*)titleLabel {
  if (!_titleLabel) {
    _titleLabel = [[UILabel alloc] initWithFrame:CGRectZero];
    [_titleLabel setBackgroundColor:self.backgroundColor];
    [_titleLabel setText:[self titleLabelText]];
    [_titleLabel setLineBreakMode:NSLineBreakByWordWrapping];
    [_titleLabel setNumberOfLines:0];
    [_titleLabel setTextColor:[UIColor colorNamed:kTextPrimaryColor]];
    [_titleLabel setFont:[UIFont systemFontOfSize:kTitleLabelFontSize
                                           weight:UIFontWeightRegular]];
  }
  return _titleLabel;
}

- (UITextView*)footerLabel {
  if (!_footerLabel) {
    _footerLabel = CreateUITextViewWithTextKit1();
    _footerLabel.backgroundColor = self.backgroundColor;
    _footerLabel.delegate = self;

    // Set base text styling for footer.
    NSDictionary<NSAttributedStringKey, id>* footerAttributes = @{
      NSFontAttributeName : [UIFont systemFontOfSize:kFooterLabelFontSize
                                              weight:UIFontWeightRegular],
      NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
    };
    NSMutableAttributedString* footerText =
        [[NSMutableAttributedString alloc] initWithString:[self footerLabelText]
                                               attributes:footerAttributes];

    // Add link to footer.
    NSURL* linkURL = net::NSURLWithGURL(GURL(kCrashReasonURL));
    NSDictionary<NSAttributedStringKey, id>* linkAttributes = @{
      NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor],
      NSLinkAttributeName : linkURL,
    };
    NSRange linkRange = [footerText.string rangeOfString:[self footerLinkText]];
    DCHECK(linkRange.location != NSNotFound);
    DCHECK(linkRange.length > 0);
    [footerText addAttributes:linkAttributes range:linkRange];

    _footerLabel.attributedText = footerText;
  }
  return _footerLabel;
}

- (CGRect)containerBounds {
  CGFloat containerWidth = std::min(
      CGRectGetWidth(self.bounds) - kLayoutInsets.left - kLayoutInsets.right,
      kLayoutBoundsMaxWidth);
  return CGRectMake(0.0, 0.0, containerWidth, CGFLOAT_MAX);
}

#pragma mark Layout

- (void)willMoveToSuperview:(nullable UIView*)newSuperview {
  [super willMoveToSuperview:newSuperview];

  if (self.containerView.superview) {
    DCHECK_EQ(self.containerView.superview, self);
    return;
  }

  [self addSubview:self.containerView];
  [self.containerView addSubview:self.imageView];
  [self.containerView addSubview:self.titleLabel];
  [self.containerView addSubview:self.messageTextView];
  [self.containerView addSubview:self.footerLabel];
}

- (void)layoutSubviews {
  [super layoutSubviews];

  [self layoutImageView];
  [self layoutTitleLabel];
  [self layoutMessageTextView];
  [self layoutFooterLabel];
  [self layoutActionButton];
  [self layoutContainerView];
}

- (CGSize)sizeThatFits:(CGSize)size {
  return size;
}

- (void)layoutImageView {
  LayoutRect imageViewLayout = LayoutRectZero;
  imageViewLayout.boundingWidth = CGRectGetWidth(self.containerBounds);
  imageViewLayout.size = self.imageView.bounds.size;
  self.imageView.frame =
      AlignRectOriginAndSizeToPixels(LayoutRectGetRect(imageViewLayout));
}

- (void)layoutTitleLabel {
  CGRect containerBounds = self.containerBounds;
  LayoutRect titleLabelLayout = LayoutRectZero;
  titleLabelLayout.boundingWidth = CGRectGetWidth(containerBounds);
  titleLabelLayout.size = [self.titleLabel sizeThatFits:containerBounds.size];
  titleLabelLayout.position.originY =
      CGRectGetMaxY(self.imageView.frame) + kTitleLabelTopPadding;
  self.titleLabel.frame =
      AlignRectOriginAndSizeToPixels(LayoutRectGetRect(titleLabelLayout));
}

- (void)layoutMessageTextView {
  CGRect containerBounds = self.containerBounds;
  LayoutRect messageTextViewLayout = LayoutRectZero;
  messageTextViewLayout.boundingWidth = CGRectGetWidth(containerBounds);
  messageTextViewLayout.size =
      [self.messageTextView sizeThatFits:containerBounds.size];
  messageTextViewLayout.position.originY =
      CGRectGetMaxY(self.titleLabel.frame) + kMessageTextViewTopPadding;
  self.messageTextView.frame =
      AlignRectOriginAndSizeToPixels(LayoutRectGetRect(messageTextViewLayout));
}

- (void)layoutFooterLabel {
  CGRect containerBounds = self.containerBounds;
  LayoutRect footerLabelLayout = LayoutRectZero;
  footerLabelLayout.boundingWidth = CGRectGetWidth(containerBounds);
  footerLabelLayout.size = [self.footerLabel sizeThatFits:containerBounds.size];
  footerLabelLayout.position.originY =
      CGRectGetMaxY(self.messageTextView.frame) + kFooterLabelTopPadding;
  self.footerLabel.frame =
      AlignRectOriginAndSizeToPixels(LayoutRectGetRect(footerLabelLayout));
}

- (void)layoutActionButton {
  CGRect containerBounds = self.containerBounds;
  BOOL isIPadIdiom = ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET;
  BOOL isPortrait = IsPortrait(self.window);
  BOOL shouldAddActionButtonToContainer = isIPadIdiom || !isPortrait;
  LayoutRect actionButtonLayout = LayoutRectZero;
  actionButtonLayout.size =
      isIPadIdiom
          ? [self.actionButton sizeThatFits:CGSizeZero]
          : CGSizeMake(CGRectGetWidth(containerBounds), kActionButtonHeight);
  if (shouldAddActionButtonToContainer) {
    // Right-align actionButton and add it below helpLabel when adding it to
    // the containerView.
    if (self.actionButton.superview != self.containerView)
      [self.containerView addSubview:self.actionButton];
    actionButtonLayout.boundingWidth = CGRectGetWidth(containerBounds);
    actionButtonLayout.position = LayoutRectPositionMake(
        CGRectGetWidth(containerBounds) - actionButtonLayout.size.width,
        CGRectGetMaxY(self.footerLabel.frame) + kActionButtonTopPadding);
  } else {
    // Bottom-align the actionButton with the bounds specified by kLayoutInsets.
    if (self.actionButton.superview != self)
      [self addSubview:self.actionButton];
    actionButtonLayout.boundingWidth = CGRectGetWidth(self.bounds);
    actionButtonLayout.position = LayoutRectPositionMake(
        UIEdgeInsetsGetLeading(kLayoutInsets),
        CGRectGetMaxY(self.bounds) - kLayoutInsets.bottom -
            actionButtonLayout.size.height);
  }
  self.actionButton.frame =
      AlignRectOriginAndSizeToPixels(LayoutRectGetRect(actionButtonLayout));
}

- (void)layoutContainerView {
  UIView* bottomSubview = self.actionButton.superview == self.containerView
                              ? self.actionButton
                              : self.footerLabel;
  CGSize containerSize = CGSizeMake(CGRectGetWidth(self.containerBounds),
                                    CGRectGetMaxY(bottomSubview.frame));
  CGFloat containerOriginX =
      (CGRectGetWidth(self.bounds) - containerSize.width) / 2.0f;
  CGFloat containerOriginY = 0.0f;
  if (ui::GetDeviceFormFactor() == ui::DEVICE_FORM_FACTOR_TABLET) {
    // Center the containerView on iPads.
    containerOriginY =
        (CGRectGetHeight(self.bounds) - containerSize.height) / 2.0f;
  } else if (IsPortrait(self.window)) {
    // Align containerView to a quarter of the view height on portrait iPhones.
    containerOriginY =
        (CGRectGetHeight(self.bounds) - containerSize.height) / 4.0f;
  } else {
    // Top-align containerView on landscape iPhones.
    containerOriginY = kContainerViewLandscapeTopPadding;
  }
  self.containerView.frame = AlignRectOriginAndSizeToPixels(
      CGRectMake(containerOriginX, containerOriginY, containerSize.width,
                 containerSize.height));
}

#pragma mark Util

- (void)handleActionButtonTapped {
  switch (self.mode) {
    case SadTabViewMode::RELOAD:
      UMA_HISTOGRAM_ENUMERATION(ui_metrics::kSadTabReloadHistogramKey,
                                ui_metrics::SadTabEvent::BUTTON_CLICKED,
                                ui_metrics::SadTabEvent::MAX_SAD_TAB_EVENT);
      [self.delegate sadTabViewReload:self];
      break;
    case SadTabViewMode::FEEDBACK: {
      UMA_HISTOGRAM_ENUMERATION(ui_metrics::kSadTabFeedbackHistogramKey,
                                ui_metrics::SadTabEvent::BUTTON_CLICKED,
                                ui_metrics::SadTabEvent::MAX_SAD_TAB_EVENT);
      [self.delegate sadTabViewShowReportAnIssue:self];
      break;
    }
  };
}

+ (UIColor*)sadTabBackgroundColor {
  return [UIColor colorNamed:kBackgroundColor];
}

#pragma mark - UITextViewDelegate

- (BOOL)textView:(UITextView*)textView
    shouldInteractWithURL:(NSURL*)URL
                  inRange:(NSRange)characterRange
              interaction:(UITextItemInteraction)interaction {
  DCHECK(self.footerLabel == textView);
  DCHECK(URL);

  [self.delegate sadTabView:self
      showSuggestionsPageWithURL:net::GURLWithNSURL(URL)];
  // Returns NO as the app is handling the opening of the URL.
  return NO;
}

@end

#pragma mark -

@implementation SadTabView (UIElements)

- (UITextView*)messageTextView {
  if (!_messageTextView) {
    _messageTextView = CreateUITextViewWithTextKit1();
    [_messageTextView setBackgroundColor:self.backgroundColor];
    [_messageTextView setAttributedText:[self messageTextViewAttributedText]];
    _messageTextView.textContainer.lineFragmentPadding = 0.0f;
    [_messageTextView setTextColor:[UIColor colorNamed:kTextSecondaryColor]];
    [_messageTextView setFont:[UIFont systemFontOfSize:kMessageTextViewFontSize
                                                weight:UIFontWeightRegular]];
    [_messageTextView setUserInteractionEnabled:NO];
  }
  return _messageTextView;
}

- (UIButton*)actionButton {
  if (!_actionButton) {
    _actionButton = [[UIButton alloc] init];
    UIButtonConfiguration* buttonConfig =
        [UIButtonConfiguration plainButtonConfiguration];
    buttonConfig.background.backgroundColor = [UIColor colorNamed:kBlueColor];
    buttonConfig.background.cornerRadius = 0.0;

    buttonConfig.baseForegroundColor =
        [UIColor colorNamed:kSolidButtonTextColor];
    UIFont* font = [UIFont systemFontOfSize:kActionButtonFontSize
                                     weight:UIFontWeightMedium];
    NSDictionary* attributes = @{NSFontAttributeName : font};
    NSMutableAttributedString* attributedString =
        [[NSMutableAttributedString alloc]
            initWithString:[self buttonText].uppercaseString
                attributes:attributes];
    buttonConfig.attributedTitle = attributedString;

    _actionButton.configuration = buttonConfig;
    _actionButton.configurationUpdateHandler = ^(UIButton* incomingButton) {
      UIButtonConfiguration* updatedConfig = incomingButton.configuration;
      switch (incomingButton.state) {
        case UIControlStateNormal: {
          updatedConfig.background.backgroundColor =
              [UIColor colorNamed:kBlueColor];
          break;
        }
        case UIControlStateDisabled:
          updatedConfig.background.backgroundColor =
              [UIColor colorNamed:kDisabledTintColor];
          break;
        case UIControlStateSelected:
        case UIControlStateFocused:
        case UIControlStateApplication:
        case UIControlStateReserved:
          break;
      }
      incomingButton.configuration = updatedConfig;
    };

    [_actionButton addTarget:self
                      action:@selector(handleActionButtonTapped)
            forControlEvents:UIControlEventTouchUpInside];
    _actionButton.pointerInteractionEnabled = YES;
    _actionButton.pointerStyleProvider =
        CreateOpaqueButtonPointerStyleProvider();
  }
  return _actionButton;
}

@end