chromium/ios/chrome/browser/plus_addresses/ui/plus_address_bottom_sheet_view_controller.mm

// Copyright 2023 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/plus_addresses/ui/plus_address_bottom_sheet_view_controller.h"

#import "base/functional/bind.h"
#import "base/logging.h"
#import "base/strings/sys_string_conversions.h"
#import "base/time/time.h"
#import "base/types/expected.h"
#import "build/branding_buildflags.h"
#import "components/grit/components_resources.h"
#import "components/plus_addresses/features.h"
#import "components/plus_addresses/metrics/plus_address_metrics.h"
#import "components/strings/grit/components_strings.h"
#import "ios/chrome/browser/plus_addresses/ui/plus_address_bottom_sheet_constants.h"
#import "ios/chrome/browser/plus_addresses/ui/plus_address_bottom_sheet_delegate.h"
#import "ios/chrome/browser/shared/public/commands/browser_coordinator_commands.h"
#import "ios/chrome/browser/shared/ui/symbols/symbols.h"
#import "ios/chrome/browser/shared/ui/table_view/table_view_utils.h"
#import "ios/chrome/browser/shared/ui/util/uikit_ui_util.h"
#import "ios/chrome/common/string_util.h"
#import "ios/chrome/common/ui/colors/semantic_color_names.h"
#import "ios/chrome/common/ui/confirmation_alert/confirmation_alert_action_handler.h"
#import "ios/chrome/common/ui/confirmation_alert/confirmation_alert_view_controller.h"
#import "ios/chrome/common/ui/util/constraints_ui_util.h"
#import "ios/chrome/common/ui/util/dynamic_type_util.h"
#import "ios/chrome/common/ui/util/text_view_util.h"
#import "ios/chrome/common/ui/util/ui_util.h"
#import "ui/base/l10n/l10n_util_mac.h"

namespace {

using PlusAddressModalCompletionStatus =
    plus_addresses::metrics::PlusAddressModalCompletionStatus;

// Generates the notice to be displayed in the bottomsheet, which includes an
// attributed string.
NSAttributedString* NoticeMessage(NSString* primaryEmailAddress) {
  // Create and format the text.
  NSDictionary* text_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
  };

  NSString* message =
      l10n_util::GetNSStringF(IDS_PLUS_ADDRESS_BOTTOMSHEET_NOTICE_IOS,
                              base::SysNSStringToUTF16(primaryEmailAddress));

  NSDictionary* link_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline],
    NSUnderlineStyleAttributeName : @(NSUnderlineStyleSingle),
    // Opening notice page is handled by the delegate.
    NSLinkAttributeName : @"",
  };

  return AttributedStringFromStringWithLink(message, text_attributes,
                                            link_attributes);
}

// Generates the description to be displayed in the bottomsheet when the notice
// is presented.
NSAttributedString* DescriptionMessageOnNoticeDisplayed() {
  // Create and format the text.
  NSDictionary* text_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
  };

  NSString* message = l10n_util::GetNSString(
      IDS_PLUS_ADDRESS_BOTTOMSHEET_DESCRIPTION_NOTICE_SCREEN);

  return [[NSMutableAttributedString alloc] initWithString:message
                                                attributes:text_attributes];
}

// Generates the description to be displayed in the bottomsheet that contains
// the email.
NSAttributedString* DescriptionMessageWithEmail(NSString* primaryEmailAddress) {
  // Create and format the text.
  NSDictionary* text_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline]
  };

  NSString* message =
      l10n_util::GetNSStringF(IDS_PLUS_ADDRESS_BOTTOMSHEET_DESCRIPTION_IOS,
                              base::SysNSStringToUTF16(primaryEmailAddress));

  return [[NSMutableAttributedString alloc] initWithString:message
                                                attributes:text_attributes];
}

// Generate the error message with link to report error for displaying on the
// bottom sheet.
NSAttributedString* ErrorMessage() {
  NSDictionary* text_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kTextSecondaryColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]
  };
  NSString* message = l10n_util::GetNSString(
      IDS_PLUS_ADDRESS_BOTTOMSHEET_REPORT_ERROR_INSTRUCTION_IOS);
  NSDictionary* link_attributes = @{
    NSForegroundColorAttributeName : [UIColor colorNamed:kBlueColor],
    NSFontAttributeName :
        [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote],
    // Opening error report page is handled by the delegate.
    NSLinkAttributeName : @"",
  };

  return AttributedStringFromStringWithLink(message, text_attributes,
                                            link_attributes);
}

// Returns the image view with the branding image.
UIImageView* BrandingImageView() {
#if BUILDFLAG(IOS_USE_BRANDED_SYMBOLS)
  // Branding icon inside the container with the white background.
  return [[UIImageView alloc]
      initWithImage:MakeSymbolMulticolor(CustomSymbolWithPointSize(
                        kGoogleIconSymbol, kPlusAddressSheetBrandingIconSize))];
#else
  return [[UIImageView alloc]
      initWithImage:DefaultSymbolTemplateWithPointSize(
                        kMailFillSymbol, kPlusAddressSheetBrandingIconSize)];
#endif  // BUILDFLAG(IOS_USE_BRANDED_SYMBOLS)
}

}  // namespace

@interface PlusAddressBottomSheetViewController () <
    ConfirmationAlertActionHandler,
    UIAdaptivePresentationControllerDelegate,
    UITableViewDataSource,
    UITableViewDelegate,
    UITextViewDelegate>
@end

@implementation PlusAddressBottomSheetViewController {
  // The delegate that wraps PlusAddressService operations (reserve, confirm,
  // etc.).
  __weak id<PlusAddressBottomSheetDelegate> _delegate;
  // A commands handler that allows dismissing the bottom sheet.
  __weak id<BrowserCoordinatorCommands> _browserCoordinatorHandler;
  // The reserved plus address label, once it is ready.
  NSString* _reservedPlusAddress;
  // The table view that displays the reserved plus address for confirmation.
  UITableView* _reservedPlusAddressTableView;
  // The description of plus address that will be displayed on the bottom sheet.
  UITextView* _description;
  // The error message with error report instruction that will be shown when
  // error occurs.
  UITextView* _errorMessage;
  // A loading spinner to indicate to the user that an action is in progress.
  UIActivityIndicatorView* _activityIndicator;
  // Record of the time the bottom sheet is shown.
  base::Time _bottomSheetShownTime;
  // Error that occurred while bottom sheet is showing.
  std::optional<PlusAddressModalCompletionStatus> _bottomSheetErrorStatus;
  // Keeps track of the number of times the refresh button was hit.
  NSInteger _refreshCount;
  // The notice message if it will be shown.
  UITextView* _noticeMessage;
}

- (instancetype)initWithDelegate:(id<PlusAddressBottomSheetDelegate>)delegate
    withBrowserCoordinatorCommands:
        (id<BrowserCoordinatorCommands>)browserCoordinatorHandler {
  self = [super init];
  if (self) {
    _delegate = delegate;
    _browserCoordinatorHandler = browserCoordinatorHandler;
    _reservedPlusAddress = l10n_util::GetNSString(
        IDS_PLUS_ADDRESS_BOTTOMSHEET_LOADING_TEMPORARY_LABEL_CONTENT_IOS);
    _refreshCount = 0;
  }
  return self;
}

#pragma mark - UIViewController

- (void)viewDidLoad {
  // Set the properties read by the super when constructing the
  // views in `-[ConfirmationAlertViewController viewDidLoad]`.
  [self setupAboveTitleView];

  self.aboveTitleView = [self brandingIconView];
  self.titleString =
      l10n_util::GetNSString([_delegate shouldShowNotice]
                                 ? IDS_PLUS_ADDRESS_BOTTOMSHEET_TITLE_NOTICE_IOS
                                 : IDS_PLUS_ADDRESS_BOTTOMSHEET_TITLE_IOS);
  self.titleTextStyle = UIFontTextStyleTitle2;
  self.primaryActionString =
      l10n_util::GetNSString(IDS_PLUS_ADDRESS_BOTTOMSHEET_OK_TEXT_IOS);
  self.secondaryActionString =
      l10n_util::GetNSString(IDS_PLUS_ADDRESS_BOTTOMSHEET_CANCEL_TEXT_IOS);
  self.customScrollViewBottomInsets = 0;

  // Don't show the dismiss bar button (with the secondary button used for
  // canceling), and ensure there is still sufficient space between the top of
  // the bottom sheet content and the top of the sheet. This is especially
  // relevant with larger accessibility text sizes.
  self.showDismissBarButton = NO;
  self.topAlignedLayout = YES;
  self.customSpacingBeforeImageIfNoNavigationBar =
      kPlusAddressSheetBeforeImageTopMargin;
  self.customSpacingAfterImage = kPlusAddressSheetAfterImageMargin;

  self.underTitleView = [self setUpUnderTitleView];
  [super viewDidLoad];
  [self setUpBottomSheetDetents];
  self.actionHandler = self;
  self.presentationController.delegate = self;
  // Disable the primary button until such time as the reservation is complete.
  // If reserving an address fails, we should inform the user and not attempt to
  // fill any fields on the page.
  self.primaryActionButton.enabled = NO;
  [_delegate reservePlusAddress];
  plus_addresses::metrics::RecordModalEvent(
      plus_addresses::metrics::PlusAddressModalEvent::kModalShown,
      [_delegate shouldShowNotice]);
  _bottomSheetShownTime = base::Time::Now();
}

#pragma mark - ConfirmationAlertActionHandler

- (void)confirmationAlertPrimaryAction {
  self.primaryActionButton.enabled = NO;
  // Make sure the user perceives that something is happening via a spinner.
  [_activityIndicator startAnimating];
  [_delegate confirmPlusAddress];
  plus_addresses::metrics::RecordModalEvent(
      plus_addresses::metrics::PlusAddressModalEvent::kModalConfirmed,
      [_delegate shouldShowNotice]);
}

- (void)confirmationAlertSecondaryAction {
  // The cancel button was tapped, which dismisses the bottom sheet.
  // Call out to the command handler to hide the view and stop the coordinator.
  [self dismiss];
  [_browserCoordinatorHandler dismissPlusAddressBottomSheet];
}

#pragma mark - PlusAddressBottomSheetConsumer

- (void)didReservePlusAddress:(NSString*)plusAddress {
  self.primaryActionButton.enabled = YES;
  _reservedPlusAddress = plusAddress;
  [_reservedPlusAddressTableView reloadData];
}

- (void)didConfirmPlusAddress {
  plus_addresses::metrics::RecordModalShownOutcome(
      PlusAddressModalCompletionStatus::kModalConfirmed,
      base::Time::Now() - _bottomSheetShownTime,
      /*refresh_count=*/(int)_refreshCount, [_delegate shouldShowNotice]);
  [_activityIndicator stopAnimating];
  [_browserCoordinatorHandler dismissPlusAddressBottomSheet];
}

- (void)notifyError:(PlusAddressModalCompletionStatus)status {
  // With any error, whether during the reservation step or the confirmation
  // step, disable submission of the modal.
  _bottomSheetErrorStatus = status;
  self.primaryActionButton.enabled = NO;

  _reservedPlusAddressTableView.hidden = YES;
  [_reservedPlusAddressTableView reloadData];

  _errorMessage.hidden = NO;
  [_activityIndicator stopAnimating];
  // Resize to accommodate error message.
  [self expandBottomSheet];
}

#pragma mark - UITextViewDelegate

// Handle click on URLs on the bottomsheet.
// TODO(crbug.com/40276862) Add primaryActionForTextItem: when this method is
// deprecated after ios 17 (detail on UITextItem.h).
- (BOOL)textView:(UITextView*)textView
    shouldInteractWithURL:(NSURL*)URL
                  inRange:(NSRange)characterRange
              interaction:(UITextItemInteraction)interaction {
  CHECK(textView == _errorMessage || textView == _description);
  if (textView == _errorMessage) {
    [_delegate openNewTab:PlusAddressURLType::kErrorReport];
  } else if (textView == _noticeMessage) {
    [_delegate openNewTab:PlusAddressURLType::kLearnMore];
  } else {
    [_delegate openNewTab:PlusAddressURLType::kManagement];
  }
  [_browserCoordinatorHandler dismissPlusAddressBottomSheet];
  // Returns NO as the app is handling the opening of the URL.
  return NO;
}

#pragma mark - UIAdaptivePresentationControllerDelegate

- (void)presentationControllerDidDismiss:
    (UIPresentationController*)presentationController {
  // TODO(crbug.com/40276862): separate out the cancel click from other exit
  // patterns, on all platforms.
  [self dismiss];
}

#pragma mark - UITableViewDataSource

- (NSInteger)tableView:(UITableView*)tableView
    numberOfRowsInSection:(NSInteger)section {
  return _errorMessage.hidden ? 1 : 0;
}

- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView {
  return _errorMessage.hidden ? 1 : 0;
}

- (UITableViewCell*)tableView:(UITableView*)tableView
        cellForRowAtIndexPath:(NSIndexPath*)indexPath {
  PlusAddressSuggestionLabelCell* cell =
      DequeueTableViewCell<PlusAddressSuggestionLabelCell>(tableView);

  cell.selectionStyle = UITableViewCellSelectionStyleNone;
  cell.backgroundColor = [UIColor colorNamed:kSecondaryBackgroundColor];
#if BUILDFLAG(IOS_USE_BRANDED_SYMBOLS)
  [cell setLeadingIconImage:CustomSymbolTemplateWithPointSize(
                                kGooglePlusAddressSymbol,
                                kPlusAddressSheetCellImageSize)
              withTintColor:[UIColor colorNamed:kTextSecondaryColor]];
#else
  [cell setLeadingIconImage:DefaultSymbolTemplateWithPointSize(
                                kMailFillSymbol, kPlusAddressSheetCellImageSize)
              withTintColor:[UIColor colorNamed:kTextSecondaryColor]];
#endif
  if ([_delegate isRefreshEnabled]) {
    [cell setTrailingButtonImage:CustomSymbolTemplateWithPointSize(
                                     kArrowClockWiseSymbol,
                                     kPlusAddressSheetCellImageSize)
                   withTintColor:[UIColor colorNamed:kBlueColor]
         accessibilityIdentifier:
             kPlusAddressRefreshButtonAccessibilityIdentifier];
  }
  cell.textLabel.text = _reservedPlusAddress;
  cell.textLabel.accessibilityIdentifier =
      kPlusAddressLabelAccessibilityIdentifier;
  cell.delegate = self;

  return cell;
}

#pragma mark - PlusAddressSuggestionLabelDelegate

- (void)didTapTrailingButton {
  _refreshCount++;
  [_delegate didTapRefreshButton];
  self.primaryActionButton.enabled = NO;
  // TODO(crbug.com/343153116): Disable the refresh button when it's loading.
  _reservedPlusAddress = l10n_util::GetNSString(
      IDS_PLUS_ADDRESS_BOTTOMSHEET_REFRESH_TEMPORARY_LABEL_CONTENT_IOS);
  [_reservedPlusAddressTableView reloadData];
}

#pragma mark - Private
// Configures the reserved address view, which allows the user to understand the
// plus address they can confirm use of (or not).
- (UITableView*)reservedPlusAddressView {
  UITableView* tableViewContainer =
      [[UITableView alloc] initWithFrame:CGRectZero];
  tableViewContainer.rowHeight = kPlusAddressSheetTableViewCellHeight;
  tableViewContainer.separatorStyle = UITableViewCellSeparatorStyleNone;
  tableViewContainer.layer.cornerRadius =
      kPlusAddressSheetTableViewCellCornerRadius;
  RegisterTableViewCell<PlusAddressSuggestionLabelCell>(tableViewContainer);
  tableViewContainer.dataSource = self;
  tableViewContainer.delegate = self;
  [tableViewContainer.heightAnchor
      constraintEqualToConstant:kPlusAddressSheetTableViewCellHeight]
      .active = YES;
  return tableViewContainer;
}

// Create a description UITextView, which will describe the function of the
// feature and link out to the user's account settings.
- (UITextView*)descriptionView:(NSAttributedString*)description {
  UITextView* descriptionView = CreateUITextViewWithTextKit1();
  descriptionView.accessibilityIdentifier =
      kPlusAddressSheetDescriptionAccessibilityIdentifier;
  descriptionView.scrollEnabled = NO;
  descriptionView.editable = NO;
  descriptionView.delegate = self;
  descriptionView.backgroundColor = [UIColor clearColor];
  descriptionView.adjustsFontForContentSizeCategory = YES;
  descriptionView.translatesAutoresizingMaskIntoConstraints = NO;
  descriptionView.textContainerInset = UIEdgeInsetsZero;
  descriptionView.attributedText = description;
  descriptionView.textAlignment = NSTextAlignmentCenter;
  return descriptionView;
}

- (UITextView*)errorMessageViewWithMessage:(NSAttributedString*)message {
  UITextView* errorMessageView = CreateUITextViewWithTextKit1();
  errorMessageView.accessibilityIdentifier =
      kPlusAddressSheetErrorMessageAccessibilityIdentifier;
  errorMessageView.scrollEnabled = NO;
  errorMessageView.editable = NO;
  errorMessageView.delegate = self;
  errorMessageView.backgroundColor = [UIColor clearColor];
  errorMessageView.adjustsFontForContentSizeCategory = YES;
  errorMessageView.translatesAutoresizingMaskIntoConstraints = NO;
  errorMessageView.textContainerInset = UIEdgeInsetsZero;
  errorMessageView.attributedText = message;
  errorMessageView.textAlignment = NSTextAlignmentCenter;
  return errorMessageView;
}

- (UITextView*)noticeMessageViewWithMessage:(NSAttributedString*)message {
  UITextView* noticeMessageView = CreateUITextViewWithTextKit1();
  noticeMessageView.accessibilityIdentifier =
      kPlusAddressSheetNoticeMessageAccessibilityIdentifier;
  noticeMessageView.scrollEnabled = NO;
  noticeMessageView.editable = NO;
  noticeMessageView.delegate = self;
  noticeMessageView.backgroundColor = [UIColor clearColor];
  noticeMessageView.adjustsFontForContentSizeCategory = YES;
  noticeMessageView.translatesAutoresizingMaskIntoConstraints = NO;
  noticeMessageView.textContainerInset = UIEdgeInsetsZero;
  noticeMessageView.attributedText = message;
  noticeMessageView.textAlignment = NSTextAlignmentCenter;
  return noticeMessageView;
}

- (void)setupAboveTitleView {
  _activityIndicator = [[UIActivityIndicatorView alloc]
      initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];

  // Create a container view such that the activity indicator showing doesn't
  // cause the layout to jump.
  UIView* container = [[UIView alloc] initWithFrame:CGRectZero];
  [container addSubview:_activityIndicator];
  _activityIndicator.translatesAutoresizingMaskIntoConstraints = NO;
  container.translatesAutoresizingMaskIntoConstraints = NO;
  AddSameConstraints(container, _activityIndicator);
  self.aboveTitleView = container;
}

- (UIView*)setUpUnderTitleView {
  // Set up the view that will indicate the reserved plus address to the user
  // for confirmation.
  NSString* email = [_delegate primaryEmailAddress];
  BOOL showNotice = [_delegate shouldShowNotice];
  _reservedPlusAddressTableView = [self reservedPlusAddressView];
  _description =
      [self descriptionView:(showNotice ? DescriptionMessageOnNoticeDisplayed()
                                        : DescriptionMessageWithEmail(email))];
  _errorMessage = [self errorMessageViewWithMessage:ErrorMessage()];
  _noticeMessage =
      [self noticeMessageViewWithMessage:NoticeMessage(
                                             [_delegate primaryEmailAddress])];

  UIStackView* verticalStack = [[UIStackView alloc] initWithArrangedSubviews:@[
    _description, _reservedPlusAddressTableView, _errorMessage, _noticeMessage
  ]];
  _errorMessage.hidden = YES;
  _noticeMessage.hidden = !showNotice;
  verticalStack.axis = UILayoutConstraintAxisVertical;
  verticalStack.spacing = 0;
  verticalStack.distribution = UIStackViewDistributionFill;
  verticalStack.layoutMarginsRelativeArrangement = YES;
  verticalStack.layoutMargins = UIEdgeInsetsMake(0, 0, 0, 0);
  verticalStack.translatesAutoresizingMaskIntoConstraints = NO;
  [verticalStack setCustomSpacing:kPlusAddressSheetPrimaryAddressBottomMargin
                        afterView:_description];
  if (showNotice) {
    [verticalStack setCustomSpacing:kPlusAddressSheetPrimaryAddressBottomMargin
                          afterView:_reservedPlusAddressTableView];
  }
  return verticalStack;
}

- (void)dismiss {
  const bool was_notice_shown = [_delegate shouldShowNotice];
  plus_addresses::metrics::RecordModalEvent(
      plus_addresses::metrics::PlusAddressModalEvent::kModalCanceled,
      was_notice_shown);
  plus_addresses::metrics::RecordModalShownOutcome(
      _bottomSheetErrorStatus.value_or(
          PlusAddressModalCompletionStatus::kModalCanceled),
      base::Time::Now() - _bottomSheetShownTime,
      /*refresh_count=*/(int)_refreshCount, was_notice_shown);
  [_browserCoordinatorHandler dismissPlusAddressBottomSheet];
}

// Returns a view of a branding icon with a white background with vertical
// padding.
- (UIView*)brandingIconView {
  // Container of the trash icon that has the red background.
  UIView* iconContainerView = [[UIView alloc] init];
  iconContainerView.translatesAutoresizingMaskIntoConstraints = NO;
  iconContainerView.layer.cornerRadius =
      kPlusAddressSheetBrandingIconContainerViewCornerRadius;
  iconContainerView.layer.shadowRadius =
      kPlusAddressSheetBrandingIconContainerViewShadowRadius;
  iconContainerView.layer.shadowOpacity =
      kPlusAddressSheetBrandingIconContainerViewShadowOpacity;
  iconContainerView.backgroundColor = [UIColor colorNamed:kSolidWhiteColor];

  UIImageView* icon = BrandingImageView();
  icon.clipsToBounds = YES;
  icon.translatesAutoresizingMaskIntoConstraints = NO;
  [iconContainerView addSubview:icon];

  [NSLayoutConstraint activateConstraints:@[
    [iconContainerView.widthAnchor
        constraintEqualToConstant:
            kPlusAddressSheetBrandingIconContainerViewSize],
    [iconContainerView.heightAnchor
        constraintEqualToConstant:
            kPlusAddressSheetBrandingIconContainerViewSize],
  ]];
  AddSameCenterConstraints(iconContainerView, icon);

  // Padding for the icon container view.
  UIView* outerView = [[UIView alloc] init];
  [outerView addSubview:iconContainerView];
  AddSameCenterXConstraint(outerView, iconContainerView);
  AddSameConstraintsToSidesWithInsets(
      iconContainerView, outerView, LayoutSides::kTop | LayoutSides::kBottom,
      NSDirectionalEdgeInsetsMake(
          kPlusAddressSheetBrandingIconContainerViewTopPadding, 0,
          kPlusAddressSheetBrandingIconContainerViewBottomPadding, 0));

  return outerView;
}

@end