chromium/ash/system/notification_center/views/ongoing_process_view.cc

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

#include "ash/system/notification_center/views/ongoing_process_view.h"

#include <string>

#include "ash/public/cpp/ash_view_ids.h"
#include "ash/public/cpp/style/color_provider.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/icon_button.h"
#include "ash/style/pill_button.h"
#include "ash/style/system_shadow.h"
#include "ash/style/typography.h"
#include "ash/system/notification_center/message_center_constants.h"
#include "ash/system/notification_center/metrics_utils.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/gfx/vector_icon_types.h"
#include "ui/message_center/message_center_impl.h"
#include "ui/message_center/public/cpp/message_center_constants.h"
#include "ui/message_center/public/cpp/notification.h"
#include "ui/message_center/views/notification_control_buttons_view.h"
#include "ui/views/background.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/layout/flex_layout.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/view.h"
#include "ui/views/view_utils.h"

namespace ash {

namespace {

constexpr gfx::Insets kInteriorMargin = gfx::Insets::TLBR(12, 18, 12, 16);
constexpr gfx::Insets kIconAndLabelContainerDefaultMargins =
    gfx::Insets::VH(0, 16);
constexpr gfx::Insets kButtonsContainerDefaultMargins = gfx::Insets::VH(0, 6);
constexpr gfx::Insets kButtonsContainerInteriorMargin =
    gfx::Insets::TLBR(0, 12, 0, 0);

constexpr int kIconSize = 20;
constexpr int kTitleMaxLines = 2;
constexpr int kSubtitleMaxLines = 2;
constexpr int kSubtitleLineHeight = 18;

NotificationCatalogName GetCatalogName(
    const message_center::Notification& notification) {
  return notification.notifier_id().type ==
                 message_center::NotifierType::SYSTEM_COMPONENT
             ? notification.notifier_id().catalog_name
             : NotificationCatalogName::kNone;
}

}  // namespace

OngoingProcessView::OngoingProcessView(
    const message_center::Notification& notification)
    : MessageView(notification) {
  // `notification` must have a title and an icon. Will record the metrics
  // `ShownWithoutTitle` and `ShownWithoutIcon` to track outliers.
  auto catalog_name = GetCatalogName(notification);
  if (notification.title().empty()) {
    metrics_utils::LogOngoingProcessShownWithoutTitle(catalog_name);
  }
  if (notification.vector_small_image().is_empty()) {
    metrics_utils::LogOngoingProcessShownWithoutIcon(catalog_name);
  }

  SetLayoutManager(std::make_unique<views::FlexLayout>())
      ->SetOrientation(views::LayoutOrientation::kHorizontal)
      .SetCrossAxisAlignment(views::LayoutAlignment::kCenter)
      .SetInteriorMargin(kInteriorMargin);

  auto* focus_ring = views::FocusRing::Get(this);
  focus_ring->SetColorId(ui::kColorAshFocusRing);
  focus_ring->SetProperty(views::kViewIgnoredByLayoutKey, true);

  auto* icon_and_label_container =
      AddChildView(views::Builder<views::FlexLayoutView>()
                       .SetIgnoreDefaultMainAxisMargins(true)
                       .SetCollapseMargins(true)
                       .Build());
  icon_and_label_container->SetDefault(views::kMarginsKey,
                                       kIconAndLabelContainerDefaultMargins);
  // Set `MaximumFlexSizeRule` to `kUnbounded` so the container expands and the
  // `buttons_container` is always on the trailing side of the notification.
  icon_and_label_container->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(
          views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                                   views::MaximumFlexSizeRule::kUnbounded,
                                   /*adjust_height_for_width=*/true)));

  icon_and_label_container->AddChildView(
      views::Builder<views::ImageView>()
          .SetID(VIEW_ID_ONGOING_PROCESS_ICON)
          .SetImage(ui::ImageModel::FromVectorIcon(
              notification.vector_small_image().is_empty()
                  ? message_center::kProductIcon
                  : notification.vector_small_image(),
              cros_tokens::kCrosSysOnSurface, kIconSize))
          .Build());

  auto* label_container = icon_and_label_container->AddChildView(
      views::Builder<views::FlexLayoutView>()
          .SetOrientation(views::LayoutOrientation::kVertical)
          .SetMainAxisAlignment(views::LayoutAlignment::kCenter)
          .Build());
  // Set `MinimumFlexSizeRule` to `kScaleToZero` so labels can shrink and elide,
  // or wrap when multi-line text is enabled.
  label_container->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(
          views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                                   views::MaximumFlexSizeRule::kUnbounded,
                                   /*adjust_height_for_width=*/true)));

  label_container->AddChildView(
      views::Builder<views::Label>()
          .CopyAddressTo(&title_label_)
          .SetID(VIEW_ID_ONGOING_PROCESS_TITLE_LABEL)
          .SetMultiLine(notification.message().empty())
          .SetMaxLines(kTitleMaxLines)
          .SetText(notification.title())
          .SetTooltipText(notification.title())
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetEnabledColorId(cros_tokens::kCrosSysOnSurface)
          .SetAutoColorReadabilityEnabled(false)
          .SetSubpixelRenderingEnabled(false)
          .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
              TypographyToken::kCrosBody2))
          .Build());

  label_container->AddChildView(
      views::Builder<views::Label>()
          .CopyAddressTo(&subtitle_label_)
          .SetID(VIEW_ID_ONGOING_PROCESS_SUBTITLE_LABEL)
          .SetVisible(!notification.message().empty())
          .SetMultiLine(true)
          .SetMaxLines(kSubtitleMaxLines)
          .SetText(notification.message())
          .SetTooltipText(notification.message())
          .SetHorizontalAlignment(gfx::ALIGN_LEFT)
          .SetEnabledColorId(cros_tokens::kCrosSysOnSurfaceVariant)
          .SetAutoColorReadabilityEnabled(false)
          .SetSubpixelRenderingEnabled(false)
          .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
              TypographyToken::kCrosAnnotation1))
          .SetLineHeight(kSubtitleLineHeight)
          .Build());

  auto* buttons_container =
      AddChildView(views::Builder<views::FlexLayoutView>()
                       .SetInteriorMargin(kButtonsContainerInteriorMargin)
                       .SetIgnoreDefaultMainAxisMargins(true)
                       .SetCollapseMargins(true)
                       .Build());
  buttons_container->SetDefault(views::kMarginsKey,
                                kButtonsContainerDefaultMargins);

  const bool has_pill_button = !notification.buttons().empty() &&
                               !notification.buttons()[0].title.empty();
  const bool has_icon_button =
      !has_pill_button && !notification.buttons().empty() &&
      !notification.buttons()[0].vector_icon->is_empty();

  if (has_pill_button) {
    const std::u16string& pill_button_title = notification.buttons()[0].title;

    buttons_container->AddChildView(
        views::Builder<PillButton>()
            .CopyAddressTo(&primary_pill_button_)
            .SetID(VIEW_ID_ONGOING_PROCESS_PILL_BUTTON)
            .SetText(pill_button_title)
            .SetTooltipText(pill_button_title)
            .SetPillButtonType(PillButton::Type::kPrimaryWithoutIcon)
            .SetCallback(base::BindRepeating(
                [](const std::string notification_id, int button_index) {
                  message_center::MessageCenter::Get()
                      ->ClickOnNotificationButton(notification_id,
                                                  button_index);
                },
                notification.id(), 0))
            .Build());
  } else if (has_icon_button) {
    int primary_index;
    message_center::ButtonInfo primary_button;

    // Check if there is a secondary icon button in the `buttons` list. Any
    // additional provided buttons will be ignored.
    if (notification.buttons().size() > 1 &&
        !notification.buttons()[1].vector_icon->is_empty()) {
      primary_index = 1;
      primary_button = notification.buttons()[primary_index];

      secondary_button_ = buttons_container->AddChildView(
          IconButton::Builder()
              .SetViewId(VIEW_ID_ONGOING_PROCESS_SECONDARY_ICON_BUTTON)
              .SetType(IconButton::Type::kMedium)
              .SetBackgroundColor(cros_tokens::kCrosSysSystemOnBase1)
              .SetVectorIcon(notification.buttons()[0].vector_icon)
              .SetAccessibleName(notification.buttons()[0].accessible_name)
              .SetCallback(base::BindRepeating(
                  [](const std::string notification_id, int button_index) {
                    message_center::MessageCenter::Get()
                        ->ClickOnNotificationButton(notification_id,
                                                    button_index);
                  },
                  notification.id(), 0))
              .Build());
    } else {
      primary_index = 0;
      primary_button = notification.buttons()[primary_index];
    }

    primary_icon_button_ = buttons_container->AddChildView(
        IconButton::Builder()
            .SetViewId(VIEW_ID_ONGOING_PROCESS_PRIMARY_ICON_BUTTON)
            .SetType(IconButton::Type::kMedium)
            .SetBackgroundColor(cros_tokens::kCrosSysHighlightShape)
            .SetVectorIcon(primary_button.vector_icon)
            .SetAccessibleName(primary_button.accessible_name)
            .SetCallback(base::BindRepeating(
                [](const std::string notification_id, int button_index) {
                  message_center::MessageCenter::Get()
                      ->ClickOnNotificationButton(notification_id,
                                                  button_index);
                },
                notification.id(), primary_index))
            .Build());
  }
}

OngoingProcessView::~OngoingProcessView() = default;

void OngoingProcessView::OnFocus() {
  MessageView::OnFocus();
  ScrollRectToVisible(GetLocalBounds());
}

void OngoingProcessView::OnThemeChanged() {
  // TODO(b/325129366): Land a config in `MessageView` that states if a
  // background should be painted, so there's no need to override
  // `OnThemeChanged` to prevent it from painting a background.
  views::View::OnThemeChanged();
}

void OngoingProcessView::UpdateWithNotification(
    const message_center::Notification& notification) {
  MessageView::UpdateWithNotification(notification);

  // Only the `title` and `subtitle` labels and the label or icon inside of the
  // buttons can be updated. Any other changes will be ignored.
  if (title_label_ && title_label_->GetText() != notification.title()) {
    auto catalog_name = GetCatalogName(notification);
    if (notification.title().empty()) {
      metrics_utils::LogOngoingProcessShownWithoutTitle(catalog_name);
    }
    title_label_->SetText(notification.title());
  }

  if (subtitle_label_ && subtitle_label_->GetText() != notification.message()) {
    if (notification.message().empty()) {
      subtitle_label_->SetVisible(false);
    }
    if (subtitle_label_->GetText().empty()) {
      subtitle_label_->SetVisible(true);
    }
    subtitle_label_->SetText(notification.message());
  }

  // Return early if there were no buttons in the original notification.
  if (!primary_pill_button_ && !primary_icon_button_) {
    return;
  }

  // Buttons can't be added or removed, only their contents updated.
  const bool has_pill_button = primary_pill_button_ &&
                               !notification.buttons().empty() &&
                               !notification.buttons()[0].title.empty();
  const bool has_icon_button =
      primary_icon_button_ && !has_pill_button &&
      !notification.buttons().empty() &&
      !notification.buttons()[0].vector_icon->is_empty();

  if (has_pill_button) {
    primary_pill_button_->SetText(notification.buttons()[0].title);
    return;
  }

  if (has_icon_button) {
    int primary_button_index;
    if (secondary_button_ && notification.buttons().size() > 1 &&
        !notification.buttons()[1].vector_icon->is_empty()) {
      secondary_button_->SetVectorIcon(*notification.buttons()[0].vector_icon);
      primary_button_index = 1;
    } else {
      primary_button_index = 0;
    }

    primary_icon_button_->SetVectorIcon(
        *notification.buttons()[primary_button_index].vector_icon);
  }
}

message_center::NotificationControlButtonsView*
OngoingProcessView::GetControlButtonsView() const {
  return nullptr;
}

void OngoingProcessView::OnBoundsChanged(const gfx::Rect& previous_bounds) {
  views::FocusRing::Get(this)->InvalidateLayout();
}

BEGIN_METADATA(OngoingProcessView)
END_METADATA

}  // namespace ash