chromium/ash/system/mahi/refresh_banner_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/mahi/refresh_banner_view.h"

#include <string>

#include "ash/strings/grit/ash_strings.h"
#include "ash/style/icon_button.h"
#include "ash/style/typography.h"
#include "ash/system/mahi/mahi_constants.h"
#include "base/check_is_test.h"
#include "base/metrics/histogram_functions.h"
#include "base/strings/string_util.h"
#include "base/time/time.h"
#include "chromeos/components/mahi/public/cpp/mahi_manager.h"
#include "components/vector_icons/vector_icons.h"
#include "third_party/skia/include/core/SkPath.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/compositor/layer_animator.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/gfx/text_constants.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/background.h"
#include "ui/views/controls/label.h"
#include "ui/views/layout/flex_layout_types.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/layout/layout_types.h"
#include "ui/views/view_class_properties.h"

namespace ash {

namespace {

constexpr int kRefreshBannerCornerRadius = 20;
constexpr base::TimeDelta kRefreshBannerSlideAnimationDurationMs =
    base::Milliseconds(200);
constexpr base::TimeDelta kRefreshBannerOpacityAnimationDurationMs =
    base::Milliseconds(100);
constexpr gfx::Insets kRefreshBannerInteriorMargin =
    gfx::Insets::TLBR(4, 20, mahi_constants::kRefreshBannerStackDepth + 4, 20);
constexpr gfx::Insets kTitleLabelMargin = gfx::Insets::TLBR(0, 0, 0, 8);

SkPath GetClipPath(gfx::Size size) {
  int width = size.width();
  int height = size.height();

  auto top_left = SkPoint::Make(0, 0);
  auto top_right = SkPoint::Make(width, 0);
  auto bottom_left = SkPoint::Make(0, height);
  auto bottom_right = SkPoint::Make(width, height);
  int radius = kRefreshBannerCornerRadius;
  int bottom_radius = mahi_constants::kPanelCornerRadius;

  const auto bottom_vertical_offset =
      SkPoint::Make(0.f, mahi_constants::kRefreshBannerStackDepth - 1);
  const auto bottom_horizontal_offset = SkPoint::Make(bottom_radius, 0.f);

  return SkPath()
      // Start just before the curve of the top-left corner.
      .moveTo(radius, 0.f)
      // Draw the top-left rounded corner.
      .lineTo(top_left)
      // Draw the bottom-left rounded corner and the vertical line
      // connecting it to the top-left corner.
      .lineTo(bottom_left)
      .arcTo(bottom_left - bottom_vertical_offset,
             bottom_left - bottom_vertical_offset + bottom_horizontal_offset,
             radius)
      // Draw the bottom-right rounded corner and the horizontal line
      // connecting it to the bottom-left corner.
      .arcTo(bottom_right - bottom_vertical_offset, bottom_right, bottom_radius)
      .lineTo(bottom_right)
      .lineTo(top_right)
      .close();
}

}  // namespace

RefreshBannerView::RefreshBannerView(MahiUiController* ui_controller)
    : MahiUiController::Delegate(ui_controller), ui_controller_(ui_controller) {
  CHECK(ui_controller_);

  SetUseDefaultFillLayout(true);
  SetID(mahi_constants::ViewId::kRefreshView);
  SetBackground(views::CreateThemedRoundedRectBackground(
      cros_tokens::kCrosSysSystemPrimaryContainer, /*radius=*/0));
  SetVisible(false);

  // We need to paint this view to a layer for animations.
  SetPaintToLayer();
  layer()->SetRoundedCornerRadius(gfx::RoundedCornersF(
      kRefreshBannerCornerRadius, kRefreshBannerCornerRadius, 0, 0));

  auto* const manager = chromeos::MahiManager::Get();

  AddChildView(
      views::Builder<views::FlexLayoutView>()
          .CopyAddressTo(&main_container_)
          .SetOrientation(views::LayoutOrientation::kHorizontal)
          .SetInteriorMargin(kRefreshBannerInteriorMargin)
          .SetPaintToLayer()
          .AddChild(
              views::Builder<views::Label>()
                  .CopyAddressTo(&title_label_)
                  .SetID(mahi_constants::ViewId::kBannerTitleLabel)
                  .SetText(l10n_util::GetStringFUTF16(
                      IDS_ASH_MAHI_REFRESH_BANNER_LABEL_TEXT,
                      manager ? manager->GetContentTitle()
                              : base::EmptyString16()))
                  .SetAutoColorReadabilityEnabled(false)
                  .SetEnabledColorId(
                      cros_tokens::kCrosSysSystemOnPrimaryContainer)
                  .SetFontList(
                      TypographyProvider::Get()->ResolveTypographyToken(
                          TypographyToken::kCrosAnnotation2))
                  .SetHorizontalAlignment(gfx::ALIGN_LEFT)
                  .SetProperty(views::kFlexBehaviorKey,
                               views::FlexSpecification(
                                   views::MinimumFlexSizeRule::kScaleToZero,
                                   views::MaximumFlexSizeRule::kUnbounded))
                  .SetProperty(views::kMarginsKey, kTitleLabelMargin))
          .AddChild(views::Builder<views::Button>(CreateRefreshButton()))
          .Build());

  main_container_->layer()->SetFillsBoundsOpaquely(false);
}

RefreshBannerView::~RefreshBannerView() = default;

void RefreshBannerView::Show() {
  auto* manager = chromeos::MahiManager::Get();
  if (!manager) {
    CHECK_IS_TEST();
    return;
  }

  title_label_->SetText(l10n_util::GetStringFUTF16(
      IDS_ASH_MAHI_REFRESH_BANNER_LABEL_TEXT, manager->GetContentTitle()));

  // Abort all running animations before showing the banner, to prevent fade
  // out animations from hiding the banner again right after it is shown.
  CHECK(layer());
  layer()->GetAnimator()->AbortAllAnimations();

  if (GetVisible()) {
    return;
  }

  SetVisible(true);

  auto bounds = GetLocalBounds();
  auto clip_rect = gfx::Rect(0, bounds.height() - 1, bounds.width(), 1);

  layer()->SetClipRect(clip_rect);
  layer()->SetOpacity(0.0);

  // Anchor the panel's contents to the center of the visible area.
  auto y_offset =
      clip_rect.CenterPoint().y() - main_container_->bounds().CenterPoint().y();
  gfx::Transform transform;
  transform.Translate(gfx::Vector2d(0, y_offset));
  main_container_->layer()->SetTransform(transform);

  views::AnimationBuilder()
      .Once()
      .SetDuration(kRefreshBannerSlideAnimationDurationMs)
      .SetTransform(main_container_, gfx::Transform(),
                    gfx::Tween::ACCEL_20_DECEL_100)
      .SetClipRect(this, gfx::Rect(bounds), gfx::Tween::ACCEL_20_DECEL_100)
      .At(base::Milliseconds(0))
      .SetDuration(kRefreshBannerOpacityAnimationDurationMs)
      .SetOpacity(this, 1.0);
}

void RefreshBannerView::Hide() {
  if (GetVisible()) {
    views::AnimationBuilder()
        .OnEnded(base::BindOnce(
            [](const base::WeakPtr<views::View>& view) {
              if (view) {
                view->SetVisible(false);
              }
            },
            weak_ptr_factory_.GetWeakPtr()))
        .Once()
        .SetDuration(kRefreshBannerOpacityAnimationDurationMs)
        .SetOpacity(this, 0.0);
  }
}

std::unique_ptr<IconButton> RefreshBannerView::CreateRefreshButton() {
  auto icon_button =
      IconButton::Builder()
          .SetViewId(mahi_constants::ViewId::kRefreshButton)
          .SetCallback(base::BindRepeating(
              [](MahiUiController* ui_controller) {
                ui_controller->RefreshContents();
                base::UmaHistogramEnumeration(
                    mahi_constants::kMahiButtonClickHistogramName,
                    mahi_constants::PanelButton::kRefreshButton);
              },
              // Using `base::Unretained()` is safe here since
              // `ui_controller` outlives this `RefreshBannerView`.
              base::Unretained(ui_controller_)))
          .SetVectorIcon(&vector_icons::kReloadChromeRefreshIcon)
          .SetType(IconButton::Type::kSmallProminentFloating)
          .SetAccessibleName(l10n_util::GetStringUTF16(
              IDS_ASH_MAHI_REFRESH_BANNER_BUTTON_ACCESSIBLE_NAME))
          .Build();
  icon_button->SetIconColor(cros_tokens::kCrosSysSystemOnPrimaryContainer);
  views::FocusRing::Get(icon_button.get())
      ->SetColorId(cros_tokens::kCrosSysSystemOnPrimaryContainer);

  return icon_button;
}

void RefreshBannerView::OnBoundsChanged(const gfx::Rect& old_bounds) {
  SetClipPath(GetClipPath(GetContentsBounds().size()));
}

void RefreshBannerView::ViewHierarchyChanged(
    const views::ViewHierarchyChangedDetails& details) {
  // Make sure the refresh banner is always shown on top.
  if (layer() && layer()->parent()) {
    layer()->parent()->StackAtTop(layer());
  }
}

void RefreshBannerView::VisibilityChanged(View* starting_from,
                                          bool is_visible) {
  if (!is_visible || GetContentsBounds().size().IsZero()) {
    return;
  }
  SetClipPath(GetClipPath(GetContentsBounds().size()));
}

views::View* RefreshBannerView::GetView() {
  return this;
}

bool RefreshBannerView::GetViewVisibility(VisibilityState state) const {
  // Do not change visibility because visibility depends on the refresh
  // availability instead of `state`.
  return GetVisible();
}

void RefreshBannerView::OnUpdated(const MahiUiUpdate& update) {
  switch (update.type()) {
    case MahiUiUpdateType::kRefreshAvailabilityUpdated:
      if (update.GetRefreshAvailability()) {
        Show();
      } else {
        Hide();
      }
      return;
    case MahiUiUpdateType::kContentsRefreshInitiated:
      Hide();
      return;
    case MahiUiUpdateType::kErrorReceived:
    case MahiUiUpdateType::kAnswerLoaded:
    case MahiUiUpdateType::kOutlinesLoaded:
    case MahiUiUpdateType::kQuestionAndAnswerViewNavigated:
    case MahiUiUpdateType::kQuestionPosted:
    case MahiUiUpdateType::kQuestionReAsked:
    case MahiUiUpdateType::kSummaryLoaded:
    case MahiUiUpdateType::kSummaryAndOutlinesSectionNavigated:
    case MahiUiUpdateType::kSummaryAndOutlinesReloaded:
      return;
  }
}

BEGIN_METADATA(RefreshBannerView)
END_METADATA

}  // namespace ash