chromium/ash/glanceables/common/glanceables_time_management_bubble_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/glanceables/common/glanceables_time_management_bubble_view.h"

#include "ash/glanceables/common/glanceables_contents_scroll_view.h"
#include "ash/glanceables/common/glanceables_list_footer_view.h"
#include "ash/glanceables/common/glanceables_progress_bar_view.h"
#include "ash/glanceables/common/glanceables_view_id.h"
#include "ash/public/cpp/metrics_util.h"
#include "ash/style/combobox.h"
#include "ash/style/icon_button.h"
#include "ash/style/typography.h"
#include "base/metrics/histogram_functions.h"
#include "base/time/time.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/combobox_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/compositor.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/gfx/animation/tween.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/controls/label.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/widget/widget.h"

namespace ash {
namespace {

// The interior margin should be 12, but space needs to be left for the focus in
// the child views.
constexpr int kTotalInteriorMargin = 12;
constexpr int kSpaceForFocusRing = 4;
constexpr int kInteriorGlanceableBubbleMargin =
    kTotalInteriorMargin - kSpaceForFocusRing;

constexpr auto kHeaderIconButtonMargins = gfx::Insets::TLBR(0, 0, 0, 2);

constexpr int kScrollViewBottomMargin = 12;
constexpr int kListViewBetweenChildSpacing = 4;
constexpr gfx::Insets kFooterBorderInsets = gfx::Insets::TLBR(4, 6, 8, 2);

constexpr base::TimeDelta kExpandStateChangeAnimationDuration =
    base::Milliseconds(300);
constexpr base::TimeDelta kBubbleExpandAnimationDuration =
    base::Milliseconds(300);
constexpr base::TimeDelta kBubbleCollapseAnimationDuration =
    base::Milliseconds(250);
constexpr gfx::Tween::Type kBubbleAnimationTweenType =
    gfx::Tween::FAST_OUT_SLOW_IN;
constexpr gfx::Tween::Type kExpandStateChangeAnimationTweenType =
    gfx::Tween::ACCEL_5_70_DECEL_90;

}  // namespace

GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    GlanceablesExpandButton() = default;
GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    ~GlanceablesExpandButton() = default;

void GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    SetExpandedStateTooltipStringId(int tooltip_text_id) {
  expand_tooltip_string_id_ = tooltip_text_id;
  UpdateTooltip();
}

void GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    SetCollapsedStateTooltipStringId(int tooltip_text_id) {
  collapse_tooltip_string_id_ = tooltip_text_id;
  UpdateTooltip();
}

std::u16string GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    GetExpandedStateTooltipText() const {
  // The tooltip tells users that clicking on the button will collapse the
  // glanceables bubble.
  return collapse_tooltip_string_id_ == 0
             ? u""
             : l10n_util::GetStringUTF16(collapse_tooltip_string_id_);
}

std::u16string GlanceablesTimeManagementBubbleView::GlanceablesExpandButton::
    GetCollapsedStateTooltipText() const {
  // The tooltip tells users that clicking on the button will expand the
  // glanceables bubble.
  return expand_tooltip_string_id_ == 0
             ? u""
             : l10n_util::GetStringUTF16(expand_tooltip_string_id_);
}

BEGIN_METADATA(GlanceablesTimeManagementBubbleView, GlanceablesExpandButton)
END_METADATA

GlanceablesTimeManagementBubbleView::ResizeAnimation::ResizeAnimation(
    int start_height,
    int end_height,
    gfx::AnimationDelegate* delegate,
    Type type)
    : gfx::LinearAnimation(delegate),
      type_(type),
      start_height_(start_height),
      end_height_(end_height) {
  base::TimeDelta duration;
  switch (type) {
    case Type::kContainerExpandStateChanged:
      duration = kExpandStateChangeAnimationDuration;
      break;
    case Type::kChildResize:
      duration = start_height > end_height ? kBubbleCollapseAnimationDuration
                                           : kBubbleExpandAnimationDuration;
      break;
  }
  SetDuration(duration *
              ui::ScopedAnimationDurationScaleMode::duration_multiplier());
}

int GlanceablesTimeManagementBubbleView::ResizeAnimation::GetCurrentHeight()
    const {
  auto tween_type = type_ == Type::kChildResize
                        ? kBubbleAnimationTweenType
                        : kExpandStateChangeAnimationTweenType;
  return gfx::Tween::IntValueBetween(
      gfx::Tween::CalculateValue(tween_type, GetCurrentValue()), start_height_,
      end_height_);
}

GlanceablesTimeManagementBubbleView::InitParams::InitParams() = default;
GlanceablesTimeManagementBubbleView::InitParams::InitParams(
    InitParams&& other) = default;
GlanceablesTimeManagementBubbleView::InitParams::~InitParams() = default;

GlanceablesTimeManagementBubbleView::GlanceablesTimeManagementBubbleView(
    InitParams params)
    : context_(params.context),
      combobox_model_(std::move(params.combobox_model)) {
  GetViewAccessibility().SetRole(ax::mojom::Role::kGroup);

  UpdateInteriorMargin();
  SetOrientation(views::LayoutOrientation::kVertical);

  auto* header_container =
      AddChildView(std::make_unique<views::FlexLayoutView>());
  header_container->SetMainAxisAlignment(views::LayoutAlignment::kStart);
  header_container->SetCrossAxisAlignment(views::LayoutAlignment::kCenter);
  header_container->SetOrientation(views::LayoutOrientation::kHorizontal);
  header_container->SetInteriorMargin(gfx::Insets::TLBR(
      kSpaceForFocusRing, kSpaceForFocusRing, 0, kSpaceForFocusRing));

  header_view_ =
      header_container->AddChildView(std::make_unique<views::FlexLayoutView>());
  header_view_->SetCrossAxisAlignment(views::LayoutAlignment::kCenter);
  header_view_->SetMainAxisAlignment(views::LayoutAlignment::kStart);
  header_view_->SetOrientation(views::LayoutOrientation::kHorizontal);
  header_view_->SetID(
      base::to_underlying(GlanceablesViewId::kTimeManagementBubbleHeaderView));
  header_view_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::LayoutOrientation::kHorizontal,
                               views::MinimumFlexSizeRule::kPreferred,
                               views::MaximumFlexSizeRule::kUnbounded)
          .WithWeight(1));

  auto* const header_icon = header_view_->AddChildViewAt(
      std::make_unique<IconButton>(
          base::BindRepeating(
              &GlanceablesTimeManagementBubbleView::OnHeaderIconPressed,
              base::Unretained(this)),
          IconButton::Type::kSmall, params.header_icon,
          params.header_icon_tooltip_id),
      0);
  header_icon->SetBackgroundColor(SK_ColorTRANSPARENT);
  header_icon->SetProperty(views::kMarginsKey, kHeaderIconButtonMargins);
  header_icon->SetID(
      base::to_underlying(GlanceablesViewId::kTimeManagementBubbleHeaderIcon));

  CreateComboBoxView();
  combobox_view_->SetTooltipText(params.combobox_tooltip);

  auto text_on_combobox =
      combobox_view_->GetTextForRow(GetComboboxSelectedIndex());
  combobox_replacement_label_ = header_view_->AddChildView(
      std::make_unique<views::Label>(text_on_combobox));
  combobox_replacement_label_->SetProperty(views::kMarginsKey,
                                           Combobox::kComboboxBorderInsets);
  combobox_replacement_label_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::LayoutOrientation::kHorizontal,
                               views::MinimumFlexSizeRule::kScaleToZero,
                               views::MaximumFlexSizeRule::kPreferred));
  combobox_replacement_label_->SetHorizontalAlignment(
      gfx::HorizontalAlignment::ALIGN_LEFT);
  TypographyProvider::Get()->StyleLabel(TypographyToken::kCrosTitle1,
                                        *combobox_replacement_label_);
  combobox_replacement_label_->SetAutoColorReadabilityEnabled(false);
  combobox_replacement_label_->SetEnabledColorId(
      cros_tokens::kCrosSysOnSurface);
  combobox_replacement_label_->SetVisible(false);

  expand_button_ = header_container->AddChildView(
      std::make_unique<GlanceablesExpandButton>());
  expand_button_->SetID(base::to_underlying(
      GlanceablesViewId::kTimeManagementBubbleExpandButton));
  expand_button_->SetExpandedStateTooltipStringId(
      params.expand_button_tooltip_id);
  expand_button_->SetCollapsedStateTooltipStringId(
      params.collapse_button_tooltip_id);
  // This is only set visible when both Tasks and Classroom exist, where the
  // elevated background is created in that case.
  expand_button_->SetVisible(false);
  expand_button_->SetCallback(base::BindRepeating(
      &GlanceablesTimeManagementBubbleView::ToggleExpandState,
      base::Unretained(this)));

  progress_bar_ = AddChildView(std::make_unique<GlanceablesProgressBarView>());
  progress_bar_->UpdateProgressBarVisibility(/*visible=*/false);

  content_scroll_view_ =
      AddChildView(std::make_unique<GlanceablesContentsScrollView>(context_));

  auto* const list_view = content_scroll_view_->SetContents(
      views::Builder<views::BoxLayoutView>()
          .SetOrientation(views::BoxLayout::Orientation::kVertical)
          .SetInsideBorderInsets(gfx::Insets::TLBR(0, kSpaceForFocusRing,
                                                   kScrollViewBottomMargin,
                                                   kSpaceForFocusRing))
          .SetBetweenChildSpacing(kListViewBetweenChildSpacing)
          .Build());

  items_container_view_ =
      list_view->AddChildView(std::make_unique<views::View>());
  items_container_view_->GetViewAccessibility().SetRole(ax::mojom::Role::kList);
  items_container_view_->SetID(base::to_underlying(
      GlanceablesViewId::kTimeManagementBubbleListContainer));
  items_container_view_->SetLayoutManager(std::make_unique<views::BoxLayout>(
      views::BoxLayout::Orientation::kVertical,
      /*inside_border_insets=*/gfx::Insets(), kListViewBetweenChildSpacing));

  list_footer_view_ = list_view->AddChildView(
      std::make_unique<GlanceablesListFooterView>(base::BindRepeating(
          &GlanceablesTimeManagementBubbleView::OnFooterButtonPressed,
          base::Unretained(this))));
  list_footer_view_->SetID(
      base::to_underlying(GlanceablesViewId::kTimeManagementBubbleListFooter));
  list_footer_view_->SetBorder(views::CreateEmptyBorder(kFooterBorderInsets));
  list_footer_view_->SetVisible(false);
  list_footer_view_->SetTitleText(params.footer_title);
  list_footer_view_->SetSeeAllAccessibleName(params.footer_tooltip);
}

GlanceablesTimeManagementBubbleView::~GlanceablesTimeManagementBubbleView() =
    default;

void GlanceablesTimeManagementBubbleView::ChildPreferredSizeChanged(
    View* child) {
  if (child->GetProperty(views::kViewIgnoredByLayoutKey)) {
    return;
  }

  PreferredSizeChanged();
}

void GlanceablesTimeManagementBubbleView::Layout(PassKey) {
  LayoutSuperclass<views::View>(this);
  if (error_message_) {
    error_message_->UpdateBoundsToContainer(GetLocalBounds());
  }
}

gfx::Size GlanceablesTimeManagementBubbleView::GetMinimumSize() const {
  gfx::Size minimum_size = views::FlexLayoutView::GetMinimumSize();
  minimum_size.set_height(GetCollapsedStatePreferredHeight());
  return minimum_size;
}

gfx::Size GlanceablesTimeManagementBubbleView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  // The animation was implemented to ignore `available_size`. See b/351880846
  // for more detail.
  const gfx::Size base_preferred_size =
      views::FlexLayoutView::CalculatePreferredSize({});

  if (resize_animation_) {
    return gfx::Size(base_preferred_size.width(),
                     resize_animation_->GetCurrentHeight());
  }

  return base_preferred_size;
}

void GlanceablesTimeManagementBubbleView::AnimationEnded(
    const gfx::Animation* animation) {
  if (resize_throughput_tracker_) {
    resize_throughput_tracker_->Stop();
    resize_throughput_tracker_.reset();
  }
  resize_animation_.reset();
  if (resize_animation_ended_closure_) {
    std::move(resize_animation_ended_closure_).Run();
  }

  PreferredSizeChanged();
}

void GlanceablesTimeManagementBubbleView::AnimationProgressed(
    const gfx::Animation* animation) {
  PreferredSizeChanged();
}

void GlanceablesTimeManagementBubbleView::AnimationCanceled(
    const gfx::Animation* animation) {
  if (resize_throughput_tracker_) {
    resize_throughput_tracker_->Cancel();
    resize_throughput_tracker_.reset();
  }
  resize_animation_.reset();
  if (!resize_animation_ended_closure_.is_null()) {
    std::move(resize_animation_ended_closure_).Run();
  }
}

void GlanceablesTimeManagementBubbleView::AddObserver(Observer* observer) {
  observers_.AddObserver(observer);
}

void GlanceablesTimeManagementBubbleView::RemoveObserver(Observer* observer) {
  observers_.RemoveObserver(observer);
}

void GlanceablesTimeManagementBubbleView::CreateElevatedBackground() {
  SetBackground(views::CreateThemedRoundedRectBackground(
      cros_tokens::kCrosSysSystemOnBaseOpaque, 16.f));
  UpdateInteriorMargin();

  expand_button_->SetVisible(true);
  expand_button_->SetExpanded(is_expanded_);
  content_scroll_view_->SetOnOverscrollCallback(base::BindRepeating(
      &GlanceablesTimeManagementBubbleView::SetExpandState,
      base::Unretained(this),
      /*is_expanded=*/false, /*expand_by_overscroll=*/true));
}

void GlanceablesTimeManagementBubbleView::SetExpandState(
    bool is_expanded,
    bool expand_by_overscroll) {
  if (is_expanded_ == is_expanded) {
    return;
  }

  is_expanded_ = is_expanded;
  expand_button_->SetExpanded(is_expanded);

  progress_bar_->SetVisible(is_expanded_);
  content_scroll_view_->SetVisible(is_expanded_);
  combobox_view_->SetVisible(is_expanded_);
  combobox_replacement_label_->SetVisible(!is_expanded_);

  if (is_expanded) {
    if (expand_by_overscroll) {
      content_scroll_view_->LockScroll();
    } else {
      content_scroll_view_->UnlockScroll();
    }
  }

  UpdateInteriorMargin();

  for (auto& observer : observers_) {
    observer.OnExpandStateChanged(context_, is_expanded_, expand_by_overscroll);
  }

  AnimateResize(ResizeAnimation::Type::kContainerExpandStateChanged);
}

int GlanceablesTimeManagementBubbleView::GetCollapsedStatePreferredHeight()
    const {
  return kTotalInteriorMargin * 2 +
         combobox_replacement_label_->GetLineHeight() +
         Combobox::kComboboxBorderInsets.height();
}

void GlanceablesTimeManagementBubbleView::SetAnimationEndedClosureForTest(
    base::OnceClosure closure) {
  resize_animation_ended_closure_ = std::move(closure);
}

void GlanceablesTimeManagementBubbleView::UpdateInteriorMargin() {
  const bool no_bottom_margin = !GetBackground() || is_expanded_;
  SetInteriorMargin(no_bottom_margin
                        ? gfx::Insets::TLBR(kInteriorGlanceableBubbleMargin,
                                            kInteriorGlanceableBubbleMargin, 0,
                                            kInteriorGlanceableBubbleMargin)
                        : gfx::Insets::TLBR(kInteriorGlanceableBubbleMargin,
                                            kInteriorGlanceableBubbleMargin,
                                            kTotalInteriorMargin,
                                            kInteriorGlanceableBubbleMargin));
}

void GlanceablesTimeManagementBubbleView::CreateComboBoxView() {
  if (combobox_view_) {
    header_view_->RemoveChildViewT(std::exchange(combobox_view_, nullptr));
  }

  combobox_view_ = header_view_->AddChildView(
      std::make_unique<Combobox>(combobox_model_.get()));
  combobox_view_->SetID(
      base::to_underlying(GlanceablesViewId::kTimeManagementBubbleComboBox));
  combobox_view_->SetProperty(
      views::kFlexBehaviorKey,
      views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                               views::MaximumFlexSizeRule::kPreferred));
  combobox_view_->SetVisible(is_expanded_);

  // Assign a default value for tooltip and accessible text.
  combobox_view_->GetViewAccessibility().SetDescription(u"");
  combobox_view_->SetSelectionChangedCallback(base::BindRepeating(
      &GlanceablesTimeManagementBubbleView::SelectedListChanged,
      base::Unretained(this)));
}

size_t GlanceablesTimeManagementBubbleView::GetComboboxSelectedIndex() const {
  CHECK(combobox_view_->GetSelectedIndex().has_value());
  return combobox_view_->GetSelectedIndex().value();
}

void GlanceablesTimeManagementBubbleView::UpdateComboboxReplacementLabelText() {
  combobox_replacement_label_->SetText(
      combobox_view_->GetTextForRow(GetComboboxSelectedIndex()));
}

void GlanceablesTimeManagementBubbleView::SetUpResizeThroughputTracker(
    const std::string& histogram_name) {
  if (!GetWidget()) {
    return;
  }

  resize_throughput_tracker_.emplace(
      GetWidget()->GetCompositor()->RequestNewThroughputTracker());
  resize_throughput_tracker_->Start(
      ash::metrics_util::ForSmoothnessV3(base::BindRepeating(
          [](const std::string& histogram_name, int smoothness) {
            base::UmaHistogramPercentage(histogram_name, smoothness);
          },
          histogram_name)));
}

void GlanceablesTimeManagementBubbleView::MaybeDismissErrorMessage() {
  if (!error_message_.get()) {
    return;
  }

  RemoveChildViewT(std::exchange(error_message_, nullptr));
}

void GlanceablesTimeManagementBubbleView::ShowErrorMessage(
    const std::u16string& error_message,
    views::Button::PressedCallback callback,
    ErrorMessageToast::ButtonActionType type) {
  MaybeDismissErrorMessage();

  error_message_ = AddChildView(std::make_unique<ErrorMessageToast>(
      std::move(callback), error_message, type));
  error_message_->SetID(
      base::to_underlying(GlanceablesViewId::kTimeManagementErrorMessageToast));
  error_message_->SetProperty(views::kViewIgnoredByLayoutKey, true);
}

void GlanceablesTimeManagementBubbleView::ToggleExpandState() {
  SetExpandState(!is_expanded_, /*expand_by_overscroll=*/false);
}

BEGIN_METADATA(GlanceablesTimeManagementBubbleView)
END_METADATA

}  // namespace ash