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

// 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 SwiftUI

@objc public protocol MenuCustomizationEventHandler: AnyObject {
  func cancelWasTapped()
  func doneWasTapped()
}

/// View for showing the customization screen for overflow menu
struct MenuCustomizationView: View {
  /// Leading padding for any views that require it.
  static let leadingPadding: CGFloat = 16

  static let headerHeight: CGFloat = 56

  @ObservedObject var actionCustomizationModel: ActionCustomizationModel

  @ObservedObject var destinationCustomizationModel: DestinationCustomizationModel

  @ObservedObject var uiConfiguration: OverflowMenuUIConfiguration

  @StateObject var dragHandler: DestinationDragHandler

  weak var eventHandler: MenuCustomizationEventHandler?

  /// The namespace for the animation of this view appearing or disappearing.
  let namespace: Namespace.ID

  /// Focus state to allow setting VoiceOver focus to the page header when
  /// the page appears.
  @AccessibilityFocusState
  private var headerFocused: Bool

  init(
    actionCustomizationModel: ActionCustomizationModel,
    destinationCustomizationModel: DestinationCustomizationModel,
    uiConfiguration: OverflowMenuUIConfiguration,
    eventHandler: MenuCustomizationEventHandler?,
    namespace: Namespace.ID
  ) {
    self.actionCustomizationModel = actionCustomizationModel
    self.destinationCustomizationModel = destinationCustomizationModel
    self.uiConfiguration = uiConfiguration
    self.eventHandler = eventHandler
    self.namespace = namespace

    _dragHandler = StateObject(
      wrappedValue: DestinationDragHandler(destinationModel: destinationCustomizationModel))
  }

  var body: some View {
    GeometryReader { geometry in
      VStack(alignment: .leading, spacing: 0) {
        header
        OverflowMenuDestinationList(
          destinations: $destinationCustomizationModel.shownDestinations,
          width: geometry.size.width, metricsHandler: nil,
          uiConfiguration: uiConfiguration, dragHandler: dragHandler, namespace: namespace
        )
        .matchedGeometryEffect(id: MenuCustomizationAnimationID.destinations, in: namespace)
        if destinationCustomizationModel.hiddenDestinations.count > 0 {
          Text(
            L10nUtils.stringWithFixup(messageId: IDS_IOS_OVERFLOW_MENU_EDIT_SECTION_HIDDEN_TITLE)
          )
          .fontWeight(.semibold)
          .padding([.leading], Self.leadingPadding)
          .accessibilityAddTraits(.isHeader)
          OverflowMenuDestinationList(
            destinations: $destinationCustomizationModel.hiddenDestinations,
            width: geometry.size.width, metricsHandler: nil,
            uiConfiguration: uiConfiguration, namespace: namespace
          )
        }
        Divider()
        List {
          createDefaultSection {
            HStack {
              VStack(alignment: .leading) {
                Text(L10nUtils.stringWithFixup(messageId: IDS_IOS_OVERFLOW_MENU_SORT_TITLE))
                Text(L10nUtils.stringWithFixup(messageId: IDS_IOS_OVERFLOW_MENU_SORT_DESCRIPTION))
                  .font(.caption)
              }
              Spacer()
              Toggle(isOn: $destinationCustomizationModel.destinationUsageEnabled) {
                Text(L10nUtils.stringWithFixup(messageId: IDS_IOS_OVERFLOW_MENU_SORT_TITLE))
              }
              .labelsHidden()
              .tint(.chromeBlue)
            }
            .accessibilityElement(children: .combine)
          }
          OverflowMenuActionSection(
            actionGroup: actionCustomizationModel.actionsGroup, metricsHandler: nil
          )
        }
        .matchedGeometryEffect(id: MenuCustomizationAnimationID.actions, in: namespace)
      }
      .background(Color(.systemGroupedBackground).edgesIgnoringSafeArea(.all))
      .overflowMenuListStyle()
      .environment(\.editMode, .constant(.active))
      .onAppear {
        headerFocused = true
      }
    }
  }

  /// Custom header for this view. This should look like a `NavigationView`'s
  /// toolbar, but that can't be animated.
  @ViewBuilder
  var header: some View {
    HStack(alignment: .center, spacing: 0) {
      // The 3 nested HStacks mean that the space is divided equally between the
      // three. Specifically, this means that the middle HStack is centered in
      // the entire width, rather than centered between the two side buttons
      // (as they are different lengths).
      HStack {
        Button(
          L10nUtils.stringWithFixup(
            messageId: IDS_IOS_OVERFLOW_MENU_CUSTOMIZE_MENU_CANCEL)
        ) {
          eventHandler?.cancelWasTapped()
        }
        .padding([.leading])
        Spacer()
      }

      HStack {
        Text(
          L10nUtils.stringWithFixup(
            messageId: IDS_IOS_OVERFLOW_MENU_CUSTOMIZE_MENU_TITLE)
        )
        .fontWeight(.semibold)
        .lineLimit(1)
        .accessibilityFocused($headerFocused)
        .accessibilityAddTraits(.isHeader)
      }
      .layoutPriority(1000)

      HStack {
        Spacer()
        Button {
          eventHandler?.doneWasTapped()
        } label: {
          Text(
            L10nUtils.stringWithFixup(
              messageId: IDS_IOS_OVERFLOW_MENU_CUSTOMIZE_MENU_DONE)
          )
          .bold()
        }
        .disabled(
          !destinationCustomizationModel.hasChanged && !actionCustomizationModel.hasChanged
        )
        .padding([.trailing])
      }
    }
    .frame(minHeight: Self.headerHeight)
  }

  /// Creates a section with default spacing in the header and footer.
  func createDefaultSection<SectionContent: View>(content: () -> SectionContent) -> some View {
    Section(
      content: content,
      header: {
        Spacer()
          .frame(height: OverflowMenuListStyle.headerFooterHeight)
          .listRowInsets(EdgeInsets())
          .accessibilityHidden(true)
      },
      footer: {
        Spacer()
          // Use `leastNonzeroMagnitude` to remove the footer. Otherwise,
          // it uses a default height.
          .frame(height: CGFloat.leastNonzeroMagnitude)
          .listRowInsets(EdgeInsets())
          .accessibilityHidden(true)
      })
  }
}