chromium/ios/chrome/browser/ui/popup_menu/overflow_menu/overflow_menu_destination_view.swift

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

import SwiftUI
import ios_chrome_browser_shared_ui_util_util_swiftui
import ios_chrome_common_ui_colors_swift

/// `ButtonStyle` that communicates the button's `isPressed` state back to the
/// parent.
struct IsPressedStyle: ButtonStyle {
  @Binding var isPressed: Bool

  @ViewBuilder
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .onChange(of: configuration.isPressed) { newValue in
        isPressed = newValue
      }
  }
}

/// `PreferenceKey` holding the frame of the icon in the destination view.
struct IconFramePreferenceKey: PreferenceKey {
  static var defaultValue: CGRect = .null
  static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
    value = CGRectUnion(value, nextValue())
  }
}

/// A view displaying a single destination.
struct OverflowMenuDestinationView: View {
  /// Parameters providing any necessary data to layout the view.
  enum LayoutParameters {
    /// The destination has an icon on top and text below.
    /// There is `iconSpacing` to either side of the icon, and `iconPadding`
    /// around the icon and inside the background.
    case vertical(iconSpacing: CGFloat, iconPadding: CGFloat)
    case horizontal
  }

  /// Shape consisting of a path around the icon and text.
  struct IconShape: Shape {
    let iconFrame: CGRect

    func path(in rect: CGRect) -> Path {
      return Path(roundedRect: iconFrame, cornerRadius: Dimensions.cornerRadius)
    }
  }

  enum AccessibilityIdentifier {
    /// The addition to the `accessibilityIdentfier` for this element if it
    /// has an error badge.
    static let errorBadge = "errorBadge"

    /// The addition to the `accessibilityIdentfier` for this element if it
    /// has a promo badge.
    static let promoBadge = "promoBadge"

    /// The addition to the `accessibilityIdentfier` for this element if it
    /// has a "New" badge.
    static let newBadge = "newBadge"
  }

  enum Dimensions {
    static let cornerRadius: CGFloat = 13

    /// The padding on either side of the text in the vertical layout,
    /// separating it from the next view.
    static let verticalLayoutTextPadding: CGFloat = 3

    /// The padding on either side of the view in the horizontal layout,
    /// separating it from the next view.
    static let horizontalLayoutViewPadding: CGFloat = 13

    /// The padding around the icon and inside the background in horizontal
    /// layout.
    static let horizontalLayoutIconPadding: CGFloat = 3

    /// The spacing between the icon and the text in horizontal layout.
    static let horizontalLayoutIconSpacing: CGFloat = 14

    /// The image width, which controls the width of the overall view.
    static let imageWidth: CGFloat = 54

    /// The size of the Symbol in the icon.
    static let iconSymbolSize: CGFloat = 26

    /// The width of the icon, used for positioning the unread badge over the
    /// corner.
    static let iconWidth: CGFloat = 30

    /// The width of the badge circle.
    static let badgeWidth: CGFloat = 10

    /// The width of the new label badge.
    static let newLabelBadgeWidth: CGFloat = 20

    /// The top padding of the hover effect on destination items.
    static let hoverEffectTopPadding: CGFloat = 10

    /// The bottom padding of the hover effect on destination items.
    static let hoverEffectBottomPadding: CGFloat = 3
  }

  static let viewNamespace = "destinationView"

  /// The destination for this view.
  var destination: OverflowMenuDestination

  /// The layout parameters for this view.
  var layoutParameters: LayoutParameters

  var highlighted = false

  @Environment(\.editMode) var editMode

  @State private var isPressed = false

  @State private var iconFrame: CGRect = .zero

  weak var metricsHandler: PopupMenuMetricsHandler?

  var body: some View {
    button
      .coordinateSpace(name: Self.viewNamespace)
      .contentShape(
        [.contextMenuPreview, .dragPreview],
        IconShape(iconFrame: iconFrame)
      )
      .if(editMode?.wrappedValue.isEditing != true) { view in
        view.contextMenu {
          ForEach(destination.longPressItems) { item in
            Section {
              Button {
                item.handler()
              } label: {
                Label(item.title, systemImage: item.symbolName)
              }
            }
          }
        }
      }
      .accessibilityIdentifier(accessibilityIdentifier)
      .accessibilityLabel(Text(accessibilityLabel))
      .if(highlighted) { view in
        view.anchorPreference(
          key: OverflowMenuDestinationList.HighlightedDestinationBounds.self, value: .bounds
        ) { $0 }
      }
      .onPreferenceChange(IconFramePreferenceKey.self) { newFrame in
        iconFrame = newFrame
      }
  }

  // The button view, which is replaced by just a plain view when this is in
  // edit mode.
  @ViewBuilder
  var button: some View {
    if editMode?.wrappedValue.isEditing == true {
      buttonContent
    } else {
      ZStack(alignment: .top) {
        RoundedRectangle(cornerRadius: Dimensions.cornerRadius)
          .opacity(0)
        Button(
          action: {
            metricsHandler?.popupMenuTookAction()
            metricsHandler?.popupMenuUserSelectedDestination()
            destination.handler()
          },
          label: {
            buttonContent
          }
        )
        .buttonStyle(IsPressedStyle(isPressed: $isPressed))
      }
      .padding(.top, Dimensions.hoverEffectTopPadding)
      .padding(.bottom, Dimensions.hoverEffectBottomPadding)
      .contentShape(
        .hoverEffect,
        RoundedRectangle(cornerRadius: Dimensions.cornerRadius)
      )
      .hoverEffect(.automatic)
    }
  }

  /// The content of the button view.
  @ViewBuilder
  var buttonContent: some View {
    Group {
      switch layoutParameters {
      case .vertical(let iconSpacing, let iconPadding):
        VStack {
          icon
          text
        }
        .frame(
          width: Self.verticalLayoutDestinationWidth(
            iconSpacing: iconSpacing, iconPadding: iconPadding))
      case .horizontal:
        HStack {
          icon
          Spacer().frame(width: Dimensions.horizontalLayoutIconSpacing)
          text
        }
        // In horizontal layout, the item itself has leading and trailing
        // padding.
        .padding([.leading, .trailing], Dimensions.horizontalLayoutViewPadding)
      }
    }
    .contentShape(Rectangle())
  }

  /// Background color for the icon.
  var backgroundColor: Color {
    isPressed ? Color(.systemGray4) : (highlighted ? .blueHalo : .groupedSecondaryBackground)
  }

  /// View representing the background of the icon.
  @ViewBuilder
  var iconBackground: some View {
    ZStack {
      RoundedRectangle(cornerRadius: Dimensions.cornerRadius)
        .foregroundColor(backgroundColor)
      if highlighted {
        RoundedRectangle(cornerRadius: Dimensions.cornerRadius)
          .stroke(Color.chromeBlue, lineWidth: 2)
      }
    }
  }

  /// Icon for the destination.
  var icon: some View {
    let interiorPadding: CGFloat
    switch layoutParameters {
    case .vertical(_, let iconPadding):
      interiorPadding = iconPadding
    case .horizontal:
      interiorPadding = Dimensions.horizontalLayoutIconPadding
    }
    let symbolName = destination.symbolName ?? "gearshape"
    let image = (destination.systemSymbol ? Image(systemName: symbolName) : Image(symbolName))
      .renderingMode(.template)
    return iconBuilder(interiorPadding: interiorPadding, image: image)
      .overlay {
        GeometryReader { geometry in
          Color.clear.preference(
            key: IconFramePreferenceKey.self, value: geometry.frame(in: .named(Self.viewNamespace)))
        }
      }
  }

  var circleBadge: some View {
    return Circle()
      .frame(width: Dimensions.badgeWidth, height: Dimensions.badgeWidth)
      .offset(
        x: Dimensions.iconWidth - (Dimensions.badgeWidth / 2),
        y: -Dimensions.iconWidth + (Dimensions.badgeWidth / 2))
  }

  var sealBadge: some View {
    Image(systemName: "seal.fill")
      .resizable()
      .foregroundColor(.blue600)
      .frame(width: Dimensions.newLabelBadgeWidth, height: Dimensions.newLabelBadgeWidth)
      .overlay {
        if let newLabelString = L10nUtils.stringWithFixup(
          messageId: IDS_IOS_NEW_LABEL_FEATURE_BADGE)
        {
          Text(newLabelString)
            .font(.system(size: 10, weight: .bold, design: .rounded))
            .scaledToFit()
            .foregroundColor(.primaryBackground)
        }
      }
      .offset(
        x: Dimensions.iconWidth - (Dimensions.newLabelBadgeWidth - 10),
        y: -Dimensions.iconWidth + (Dimensions.newLabelBadgeWidth - 10))
  }

  /// Build the image to be displayed, based on the configuration of the item.
  /// TODO(crbug.com/40833570): Remove this once only the symbols are present.
  @ViewBuilder
  func iconBuilder(interiorPadding: CGFloat, image: Image) -> some View {
    let configuredImage = image.overlay {
      if destination.badge == .error {
        circleBadge.foregroundColor(.red500)
      } else if destination.badge == .promo {
        circleBadge.foregroundColor(.blue600)
      } else if destination.badge == .new {
        sealBadge
      }
    }
    .frame(width: Dimensions.imageWidth, height: Dimensions.imageWidth)
    .padding(interiorPadding)
    .background(iconBackground)
    // Without explicitly removing the image from accessibility,
    // VoiceOver will occasionally read out icons it thinks it can
    // recognize.
    .accessibilityHidden(true)

    configuredImage.foregroundColor(.blue600).imageScale(.medium).font(
      Font.system(size: Dimensions.iconSymbolSize, weight: .medium)
    )
    .alignmentGuide(.icon) { $0[VerticalAlignment.center] }
    .alignmentGuide(HorizontalAlignment.editButton) { $0[.leading] }
    .alignmentGuide(VerticalAlignment.editButton) { $0[.top] }
  }

  /// Text view for the destination.
  var text: some View {
    // Only the vertical layout has extra spacing around the text
    let textSpacing: CGFloat
    let maximumLines: Int?
    switch layoutParameters {
    case .vertical:
      textSpacing = Dimensions.verticalLayoutTextPadding
      maximumLines = 2
    case .horizontal:
      textSpacing = 0
      maximumLines = 1
    }
    return Text(destination.name)
      .font(.caption2)
      .padding([.leading, .trailing], textSpacing)
      .multilineTextAlignment(.center)
      .lineLimit(maximumLines)
  }

  var accessibilityLabel: String {
    return [
      destination.name,
      destination.badge == .error
        ? L10nUtils.stringWithFixup(
          messageId: IDS_IOS_ITEM_ACCOUNT_ERROR_BADGE_ACCESSIBILITY_HINT) : nil,
      destination.badge == .promo
        ? L10nUtils.stringWithFixup(messageId: IDS_IOS_NEW_ITEM_ACCESSIBILITY_HINT) : nil,
      destination.badge == .new
        ? L10nUtils.stringWithFixup(messageId: IDS_IOS_TOOLS_MENU_CELL_NEW_FEATURE_BADGE) : nil,
    ].compactMap { $0 }.joined(separator: ", ")
  }

  var accessibilityIdentifier: String {
    return [
      destination.accessibilityIdentifier,
      destination.badge == .error ? AccessibilityIdentifier.errorBadge : nil,
      destination.badge == .promo ? AccessibilityIdentifier.promoBadge : nil,
      destination.badge == .new ? AccessibilityIdentifier.newBadge : nil,
    ].compactMap { $0 }.joined(separator: "-")
  }

  static public func verticalLayoutDestinationWidth(iconSpacing: CGFloat, iconPadding: CGFloat)
    -> CGFloat
  {
    return Dimensions.imageWidth + 2 * iconSpacing + 2 * iconPadding
  }
}