chromium/ash/picker/views/picker_feature_tour.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/picker/views/picker_feature_tour.h"

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

#include "ash/constants/ash_features.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/pill_button.h"
#include "ash/style/system_dialog_delegate_view.h"
#include "base/check_op.h"
#include "base/functional/bind.h"
#include "base/functional/callback.h"
#include "base/location.h"
#include "base/task/sequenced_task_runner.h"
#include "build/branding_buildflags.h"
#include "chromeos/ash/grit/ash_resources.h"
#include "components/prefs/pref_registry_simple.h"
#include "components/prefs/pref_service.h"
#include "ui/aura/window.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/base/resource/resource_bundle.h"
#include "ui/base/ui_base_types.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/background.h"
#include "ui/views/border.h"
#include "ui/views/controls/image_view.h"
#include "ui/views/highlight_border.h"
#include "ui/views/view_class_properties.h"
#include "ui/views/widget/widget.h"
#include "ui/views/widget/widget_delegate.h"
#include "ui/wm/public/activation_client.h"

#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
#include "chromeos/ash/resources/internal/strings/grit/ash_internal_strings.h"
#endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)

namespace ash {
namespace {

constexpr auto kFeatureTourDialogBorderInsets =
    gfx::Insets::TLBR(0, 32, 28, 32);

constexpr int kFeatureTourDialogCornerRadius = 20;
constexpr auto kFeatureTourDialogIllustrationCornerRadii =
    gfx::RoundedCornersF(/*upper_left=*/kFeatureTourDialogCornerRadius,
                         /*upper_right=*/kFeatureTourDialogCornerRadius,
                         /*lower_right=*/0,
                         /*lower_left=*/0);

// Pref storing whether the feature tour was completed.
constexpr char kFeatureTourCompletedPref[] =
    "ash.picker.feature_tour.completed";

std::u16string GetHeadingText(PickerFeatureTour::EditorStatus editor_status) {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  switch (editor_status) {
    case PickerFeatureTour::EditorStatus::kEligible:
      return l10n_util::GetStringUTF16(
          IDS_PICKER_FEATURE_TOUR_WITH_EDITOR_HEADING_TEXT);
    case PickerFeatureTour::EditorStatus::kNotEligible:
      return l10n_util::GetStringUTF16(
          IDS_PICKER_FEATURE_TOUR_WITHOUT_EDITOR_HEADING_TEXT);
  }
#else
  return u"";
#endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)
}

std::u16string GetBodyText(PickerFeatureTour::EditorStatus editor_status) {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  switch (editor_status) {
    case PickerFeatureTour::EditorStatus::kEligible:
      return l10n_util::GetStringUTF16(
          IDS_PICKER_FEATURE_TOUR_WITH_EDITOR_BODY_TEXT);
    case PickerFeatureTour::EditorStatus::kNotEligible:
      return l10n_util::GetStringUTF16(
          IDS_PICKER_FEATURE_TOUR_WITHOUT_EDITOR_BODY_TEXT);
  }
#else
  return u"";
#endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)
}

ui::ImageModel GetIllustration() {
#if BUILDFLAG(GOOGLE_CHROME_BRANDING)
  return ui::ResourceBundle::GetSharedInstance().GetThemedLottieImageNamed(
      IDR_PICKER_FEATURE_TOUR_ILLUSTRATION);
#else
  return {};
#endif  // BUILDFLAG(GOOGLE_CHROME_BRANDING)
}

std::unique_ptr<views::Widget> CreateWidget(
    PickerFeatureTour::EditorStatus editor_status,
    base::OnceClosure learn_more_callback,
    base::OnceClosure completion_callback) {
  auto feature_tour_dialog =
      views::Builder<SystemDialogDelegateView>()
          .SetBorder(views::CreatePaddedBorder(
              std::make_unique<views::HighlightBorder>(
                  kFeatureTourDialogCornerRadius,
                  views::HighlightBorder::Type::kHighlightBorderOnShadow),
              kFeatureTourDialogBorderInsets))
          .SetTitleText(GetHeadingText(editor_status))
          .SetDescription(GetBodyText(editor_status))
          .SetAcceptButtonText(l10n_util::GetStringUTF16(
              IDS_PICKER_FEATURE_TOUR_START_BUTTON_LABEL))
          .SetAcceptCallback(std::move(completion_callback))
          .SetCancelButtonText(l10n_util::GetStringUTF16(
              IDS_PICKER_FEATURE_TOUR_LEARN_MORE_BUTTON_LABEL))
          .SetCancelCallback(std::move(learn_more_callback))
          .SetTopContentView(
              views::Builder<views::ImageView>()
                  .SetBackground(views::CreateThemedRoundedRectBackground(
                      cros_tokens::kCrosSysIlloColor12,
                      kFeatureTourDialogIllustrationCornerRadii))
                  .SetImage(GetIllustration()))
          .SetModalType(ui::mojom::ModalType::kSystem)
          .Build();

  views::Widget::InitParams params(views::Widget::InitParams::TYPE_POPUP);
  params.delegate = feature_tour_dialog.release();
  params.name = "PickerFeatureTourWidget";
  params.activatable = views::Widget::InitParams::Activatable::kYes;

  auto widget = std::make_unique<views::Widget>(std::move(params));
  widget->GetLayer()->SetFillsBoundsOpaquely(false);
  return widget;
}

}  // namespace

PickerFeatureTour::PickerFeatureTour() = default;

PickerFeatureTour::~PickerFeatureTour() {
  if (widget_) {
    widget_->CloseNow();
  }
}

void PickerFeatureTour::RegisterProfilePrefs(PrefRegistrySimple* registry) {
  registry->RegisterBooleanPref(kFeatureTourCompletedPref, false);
}

bool PickerFeatureTour::MaybeShowForFirstUse(
    PrefService* prefs,
    EditorStatus editor_status,
    base::RepeatingClosure learn_more_callback,
    base::RepeatingClosure completion_callback) {
  auto* pref = prefs->FindPreference(kFeatureTourCompletedPref);
  // Don't show if `pref` is null (this happens in unit tests that don't call
  // `RegisterProfilePrefs`).
  if (!base::FeatureList::IsEnabled(
          ash::features::kPickerAlwaysShowFeatureTour) &&
      (pref == nullptr || pref->GetValue()->GetBool())) {
    return false;
  }

  widget_ = CreateWidget(
      editor_status,
      base::BindOnce(&PickerFeatureTour::SetOnWindowDeactivatedCallback,
                     weak_ptr_factory_.GetWeakPtr(),
                     std::move(learn_more_callback)),
      base::BindOnce(&PickerFeatureTour::SetOnWindowDeactivatedCallback,
                     weak_ptr_factory_.GetWeakPtr(),
                     std::move(completion_callback)));

  aura::Window* window = widget_->GetNativeWindow();
  CHECK_NE(window, nullptr);
  wm::ActivationClient* activation_client =
      wm::GetActivationClient(window->GetRootWindow());
  CHECK_NE(activation_client, nullptr);
  obs_.Reset();
  obs_.Observe(activation_client);

  widget_->Show();

  prefs->SetBoolean(kFeatureTourCompletedPref, true);
  return true;
}

const views::Button* PickerFeatureTour::learn_more_button_for_testing() const {
  if (!widget_) {
    return nullptr;
  }

  auto* feature_tour_dialog =
      static_cast<SystemDialogDelegateView*>(widget_->GetContentsView());
  return feature_tour_dialog != nullptr
             ? feature_tour_dialog->GetCancelButtonForTesting()  // IN-TEST
             : nullptr;
}

const views::Button* PickerFeatureTour::complete_button_for_testing() const {
  if (!widget_) {
    return nullptr;
  }

  auto* feature_tour_dialog =
      static_cast<SystemDialogDelegateView*>(widget_->GetContentsView());
  return feature_tour_dialog != nullptr
             ? feature_tour_dialog->GetAcceptButtonForTesting()  // IN-TEST
             : nullptr;
}

views::Widget* PickerFeatureTour::widget_for_testing() {
  return widget_.get();
}

void PickerFeatureTour::OnWindowActivated(ActivationReason reason,
                                          aura::Window* gained_active,
                                          aura::Window* lost_active) {
  RunOnWindowDeactivatedIfNeeded();
}

void PickerFeatureTour::SetOnWindowDeactivatedCallback(
    base::OnceClosure callback) {
  on_window_deactivated_callback_ = std::move(callback);

  RunOnWindowDeactivatedIfNeeded();
}

void PickerFeatureTour::RunOnWindowDeactivatedIfNeeded() {
  if (on_window_deactivated_callback_.is_null()) {
    return;
  }
  if (widget_ && obs_.IsObserving() &&
      widget_->GetNativeWindow() == obs_.GetSource()->GetActiveWindow()) {
    return;
  }

  // As of writing, this method is called from two code paths:
  //
  // 1. `OnWindowActivated`, which is called from
  // `wm::FocusController::SetActiveWindow`.
  // When `OnWindowActivated` is called, the active window should be set... but
  // we cannot activate any other windows (such as Picker) synchronously due to
  // being in the middle of `wm::FocusController::SetActiveWindow`'s
  // "active window stack".
  // Doing so will cause a `DCHECK` crash in
  // `wm::FocusController::FocusAndActivateWindow` due to the active window
  // changing reentrantly. Turning off `DCHECK`s will result in no window
  // being shown / activated.
  //
  // We should only run callbacks after the `SetActiveWindow` "stack" is fully
  // resolved to avoid this. The only feasible way of doing this is to post a
  // task.
  //
  // 2. `SetOnWindowDeactivatedCallback`, which is passed in as callbacks to
  // `SystemDialogDelegateView`. Those callbacks are called from
  // `SystemDialogDelegateView::RunCallbackAndCloseDialog` before the widget is
  // closed.
  // Therefore, the active window should still be `widget_`'s native window, so
  // we should not have gotten to this point.
  //
  // However, `SystemDialogDelegateView` behaviour might change in the future.
  // The worst case would be `SystemDialogDelegateView` changing its behaviour
  // to call callbacks during `OnWindowDeactivated`, which would be equivalent
  // to the above code path. Therefore, we should also post a task in this
  // scenario.
  base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
      FROM_HERE, std::move(on_window_deactivated_callback_));
}

}  // namespace ash