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

#include <memory>
#include <string>
#include <utility>

#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/system_textfield.h"
#include "ash/style/typography.h"
#include "ash/system/mahi/mahi_animation_utils.h"
#include "ash/system/mahi/mahi_constants.h"
#include "ash/system/mahi/mahi_ui_controller.h"
#include "ash/system/mahi/mahi_ui_update.h"
#include "ash/system/mahi/mahi_utils.h"
#include "ash/system/mahi/resources/grit/mahi_resources.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/logging.h"
#include "base/metrics/histogram_functions.h"
#include "base/time/time.h"
#include "chromeos/components/mahi/public/cpp/mahi_manager.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.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/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/animated_image_view.h"
#include "ui/views/controls/focus_ring.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/controls/label.h"
#include "ui/views/controls/scroll_view.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/layout/layout_types.h"
#include "ui/views/view.h"

namespace ash {

namespace {

// Constants -------------------------------------------------------------------

// ErrorBubble
constexpr auto kErrorBubbleInteriorMargin = gfx::Insets::TLBR(/*top=*/4,
                                                              /*left=*/4,
                                                              /*bottom=*/0,
                                                              /*right=*/8);
constexpr int kErrorIconSize = 16;
constexpr auto kErrorLabelInteriorMargin =
    gfx::Insets::TLBR(/*top=*/0, /*left=*/8, /*bottom=*/0, /*right=*/0);
constexpr int kErrorLabelMaximumWidth = 276;

// MahiQuestionAnswerView
constexpr gfx::Insets kQuestionAnswerInteriorMargin(/*all=*/8);
constexpr auto kTextBubbleInteriorMargin =
    gfx::Insets::VH(/*vertical=*/8, /*horizontal=*/12);
constexpr int kBetweenChildSpacing = 8;
constexpr int kTextBubbleCornerRadius = 12;

// TODO(b/319731776): Use panel bounds here instead of `kPanelDefaultWidth` when
// the panel is resizable.
constexpr int kTextBubbleLabelDefaultMaximumWidth =
    mahi_constants::kScrollViewWidth - kQuestionAnswerInteriorMargin.width() -
    kTextBubbleInteriorMargin.width();

// ErrorBubble -----------------------------------------------------------------

// A bubble presenting the error introduced by answering a question.
class ErrorBubble : public views::FlexLayoutView {
  METADATA_HEADER(ErrorBubble, views::FlexLayoutView)
 public:
  explicit ErrorBubble(int error_text_id) {
    views::Builder<views::FlexLayoutView>(this)
        .SetBorder(views::CreateEmptyBorder(kErrorBubbleInteriorMargin))
        .SetOrientation(views::LayoutOrientation::kHorizontal)
        .AddChildren(
            views::Builder<views::ImageView>()
                .SetID(mahi_constants::ViewId::kQuestionAnswerErrorImage)
                .SetImage(ui::ImageModel::FromVectorIcon(
                    vector_icons::kErrorIcon, cros_tokens::kCrosSysSecondary,
                    kErrorIconSize)),
            views::Builder<views::Label>()
                .SetBorder(views::CreateEmptyBorder(kErrorLabelInteriorMargin))
                .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
                    TypographyToken::kCrosAnnotation1))
                .SetID(mahi_constants::ViewId::kQuestionAnswerErrorLabel)
                .SetMultiLine(true)
                .SetMaximumWidth(kErrorLabelMaximumWidth)
                .SetText(l10n_util::GetStringUTF16(error_text_id)))
        .BuildChildren();
  }
};

BEGIN_VIEW_BUILDER(ASH_EXPORT, ErrorBubble, views::FlexLayoutView)
END_VIEW_BUILDER

BEGIN_METADATA(ErrorBubble)
END_METADATA

// Creates a text bubble that will be populated with `text` and styled
// to be a question or answer based on `is_question`.
views::Builder<views::FlexLayoutView> CreateTextBubbleBuilder(
    const std::u16string& text,
    bool is_question) {
  return views::Builder<views::FlexLayoutView>()
      .SetInteriorMargin(kTextBubbleInteriorMargin)
      .SetBackground(views::CreateThemedRoundedRectBackground(
          is_question ? cros_tokens::kCrosSysSystemPrimaryContainer
                      : cros_tokens::kCrosSysSystemOnBase,
          gfx::RoundedCornersF(kTextBubbleCornerRadius)))
      .SetMainAxisAlignment(is_question ? views::LayoutAlignment::kEnd
                                        : views::LayoutAlignment::kStart)
      .CustomConfigure(base::BindOnce([](views::FlexLayoutView* layout) {
        layout->SetProperty(
            views::kFlexBehaviorKey,
            views::FlexSpecification(views::MinimumFlexSizeRule::kScaleToZero,
                                     views::MaximumFlexSizeRule::kPreferred,
                                     /*adjust_height_for_width=*/true));
      }))
      .AddChildren(
          views::Builder<views::Label>()
              // Since every text bubble label has this ID, the view lookup will
              // only be performed from one parent above.
              .SetID(mahi_constants::ViewId::kQuestionAnswerTextBubbleLabel)
              .SetSelectable(true)
              .SetMultiLine(true)
              .CustomConfigure(base::BindOnce([](views::Label* label) {
                label->SetProperty(views::kFlexBehaviorKey,
                                   views::FlexSpecification(
                                       views::MinimumFlexSizeRule::kScaleToZero,
                                       views::MaximumFlexSizeRule::kPreferred,
                                       /*adjust_height_for_width=*/true));

                // TODO(crbug.com/40233803): Multiline label right now doesn't
                // work well with `FlexLayout`. The size constraint is not
                // passed down from the views tree in the first round of layout,
                // so we impose a maximum width constraint so that the first
                // layout handle the width and height constraint correctly.
                label->SetMaximumWidth(kTextBubbleLabelDefaultMaximumWidth);
              }))
              .SetText(text)
              .SetTooltipText(text)
              .SetHorizontalAlignment(is_question ? gfx::ALIGN_RIGHT
                                                  : gfx::ALIGN_LEFT)
              .SetEnabledColorId(
                  is_question ? cros_tokens::kCrosSysSystemOnPrimaryContainer
                              : cros_tokens::kCrosSysOnSurface)
              .SetAutoColorReadabilityEnabled(false)
              .SetSubpixelRenderingEnabled(false)
              .SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
                  TypographyToken::kCrosBody2)));
}

// Create a row within the `MahiQuestionAnswerView`, corresponding to a question
// or an answer.
std::unique_ptr<views::View> CreateQuestionAnswerRow(const std::u16string& text,
                                                     bool is_question) {
  views::Builder<views::FlexLayoutView> row_builder =
      views::Builder<views::FlexLayoutView>().SetOrientation(
          views::LayoutOrientation::kHorizontal);

  views::Builder<views::FlexLayoutView> spacer =
      views::Builder<views::FlexLayoutView>().CustomConfigure(
          base::BindOnce([](views::FlexLayoutView* layout) {
            layout->SetProperty(views::kFlexBehaviorKey,
                                views::FlexSpecification(
                                    views::LayoutOrientation::kHorizontal,
                                    views::MinimumFlexSizeRule::kScaleToZero,
                                    views::MaximumFlexSizeRule::kUnbounded,
                                    /*adjust_height_for_width=*/true));
          }));

  if (is_question) {
    // Add a `FlexLayoutView` that is stretched the remaining space to the
    // left of the text bubble.
    return std::move(row_builder)
        .AddChildren(spacer, CreateTextBubbleBuilder(text, is_question))
        .Build();
  }

  // Add a `FlexLayoutView` that is stretched the remaining space to the right
  // of the text bubble.
  return std::move(row_builder)
      .AddChildren(CreateTextBubbleBuilder(text, is_question), spacer)
      .Build();
}

}  // namespace

}  // namespace ash

DEFINE_VIEW_BUILDER(ASH_EXPORT, ash::ErrorBubble)

namespace ash {

// MahiQuestionAnswerView::QuestionCountReporter -------------------------------

MahiQuestionAnswerView::QuestionCountReporter::QuestionCountReporter() =
    default;

MahiQuestionAnswerView::QuestionCountReporter::~QuestionCountReporter() =
    default;

void MahiQuestionAnswerView::QuestionCountReporter::IncreaseQuestionCount() {
  ++question_count_;
}

void MahiQuestionAnswerView::QuestionCountReporter::ReportDataAndReset() {
  base::UmaHistogramCounts100(
      mahi_constants::kQuestionCountPerMahiSessionHistogramName,
      question_count_);
  question_count_ = 0;
}

// MahiQuestionAnswerView ------------------------------------------------------

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

  SetOrientation(views::LayoutOrientation::kVertical);
  SetInteriorMargin(kQuestionAnswerInteriorMargin);
  SetIgnoreDefaultMainAxisMargins(true);
  SetCollapseMargins(true);
  SetDefault(views::kMarginsKey, gfx::Insets::VH(kBetweenChildSpacing, 0));
}

MahiQuestionAnswerView::~MahiQuestionAnswerView() {
  question_count_reporter_.ReportDataAndReset();
}

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

bool MahiQuestionAnswerView::GetViewVisibility(VisibilityState state) const {
  switch (state) {
    case VisibilityState::kQuestionAndAnswer:
      return true;
    case VisibilityState::kError:
    case VisibilityState::kSummaryAndOutlines:
      return false;
  }
}

void MahiQuestionAnswerView::OnUpdated(const MahiUiUpdate& update) {
  switch (update.type()) {
    case MahiUiUpdateType::kAnswerLoaded: {
      RemoveLoadingAnimatedImage();

      base::UmaHistogramTimes(
          mahi_constants::kAnswerLoadingTimeHistogramName,
          base::TimeTicks::Now() - answer_start_loading_time_);

      auto& answer = update.GetAnswer();

      AddChildView(CreateQuestionAnswerRow(answer, /*is_question=*/false));
      GetViewAccessibility().AnnounceText(answer);
      return;
    }
    case MahiUiUpdateType::kContentsRefreshInitiated:
      question_count_reporter_.ReportDataAndReset();
      RemoveAllChildViews();
      return;
    case MahiUiUpdateType::kErrorReceived: {
      RemoveLoadingAnimatedImage();

      // Creates `error_bubble_` if having an error.
      const MahiUiError& error = update.GetError();
      if (error.origin_state == VisibilityState::kQuestionAndAnswer) {
        AddChildView(
            views::Builder<ErrorBubble>(
                std::make_unique<ErrorBubble>(
                    mahi_utils::GetErrorStatusViewTextId(error.status)))
                .Build());
      }
      return;
    }
    case MahiUiUpdateType::kQuestionPosted: {
      question_count_reporter_.IncreaseQuestionCount();
      AddChildView(CreateQuestionAnswerRow(update.GetQuestion(),
                                           /*is_question=*/true));
      if (answer_loading_animated_image_) {
        LOG(ERROR) << "Loading animated image shouldn't be running when a "
                      "question can be asked";
        return;
      }

      auto* answer_loading_animated_image = AddChildView(
          views::Builder<views::AnimatedImageView>()
              .SetID(mahi_constants::ViewId::kAnswerLoadingAnimatedImage)
              .SetAccessibleName(l10n_util::GetStringUTF16(
                  IDS_ASH_MAHI_LOADING_ACCESSIBLE_NAME))
              .SetAnimatedImage(mahi_animation_utils::GetLottieAnimationData(
                  IDR_MAHI_LOADING_SUMMARY_ANIMATION))
              .AfterBuild(base::BindOnce([](views::AnimatedImageView* self) {
                self->Play(mahi_animation_utils::GetLottiePlaybackConfig(
                    *self->animated_image()->skottie(),
                    IDR_MAHI_LOADING_SUMMARY_ANIMATION));
              }))
              .Build());

      answer_loading_animated_image_.SetView(answer_loading_animated_image);

      answer_start_loading_time_ = base::TimeTicks::Now();

      return;
    }
    case MahiUiUpdateType::kQuestionReAsked: {
      const MahiQuestionParams& question_params =
          update.GetReAskQuestionParams();
      ui_controller_->SendQuestion(question_params.question,
                                   question_params.current_panel_content,
                                   MahiUiController::QuestionSource::kRetry);
      return;
    }
    case MahiUiUpdateType::kOutlinesLoaded:
    case MahiUiUpdateType::kQuestionAndAnswerViewNavigated:
    case MahiUiUpdateType::kRefreshAvailabilityUpdated:
    case MahiUiUpdateType::kSummaryLoaded:
    case MahiUiUpdateType::kSummaryAndOutlinesSectionNavigated:
    case MahiUiUpdateType::kSummaryAndOutlinesReloaded:
      return;
  }
}

void MahiQuestionAnswerView::RemoveLoadingAnimatedImage() {
  if (answer_loading_animated_image_) {
    RemoveChildViewT(answer_loading_animated_image_.view());
  }
}

BEGIN_METADATA(MahiQuestionAnswerView)
END_METADATA

}  // namespace ash