chromium/ash/glanceables/tasks/glanceables_tasks_view.cc

// Copyright 2023 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/tasks/glanceables_tasks_view.h"

#include <memory>
#include <optional>
#include <string>

#include "ash/api/tasks/tasks_client.h"
#include "ash/api/tasks/tasks_types.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_time_management_bubble_view.h"
#include "ash/glanceables/common/glanceables_util.h"
#include "ash/glanceables/common/glanceables_view_id.h"
#include "ash/glanceables/glanceables_controller.h"
#include "ash/glanceables/glanceables_metrics.h"
#include "ash/glanceables/tasks/glanceables_task_view.h"
#include "ash/glanceables/tasks/glanceables_tasks_combobox_model.h"
#include "ash/public/cpp/new_window_delegate.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/combobox.h"
#include "ash/style/typography.h"
#include "base/check.h"
#include "base/functional/bind.h"
#include "base/functional/callback_helpers.h"
#include "base/i18n/time_formatting.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/time/time.h"
#include "base/types/cxx23_to_underlying.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/l10n/time_format.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/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/gfx/geometry/insets.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/border.h"
#include "ui/views/controls/button/button.h"
#include "ui/views/controls/button/label_button.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/layout/flex_layout_view.h"
#include "ui/views/view.h"
#include "ui/views/view_class_properties.h"
#include "url/gurl.h"

namespace ash {
namespace {

constexpr int kListViewBetweenChildSpacing = 4;
constexpr int kMaximumTasks = 100;

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 char kTasksManagementPage[] = "https://tasks.google.com/";

constexpr char kExpandAnimationSmoothnessHistogramName[] =
    "Ash.Glanceables.TimeManagement.Tasks.Expand.AnimationSmoothness";
constexpr char kCollapseAnimationSmoothnessHistogramName[] =
    "Ash.Glanceables.TimeManagement.Tasks.Collapse.AnimationSmoothness";
constexpr char kChildResizingAnimationSmoothnessHistogramName[] =
    "Ash.Glanceables.TimeManagement.Tasks.ChildResizing.AnimationSmoothness";

// Returns the InitParams for constructor.
GlanceablesTasksView::InitParams CreateInitParamsForTasks(
    const ui::ListModel<api::TaskList>* task_lists) {
  GlanceablesTasksView::InitParams init_params;
  init_params.context = GlanceablesTasksView::Context::kTasks;
  init_params.combobox_model =
      std::make_unique<GlanceablesTasksComboboxModel>(task_lists);
  init_params.combobox_tooltip = l10n_util::GetStringFUTF16(
      IDS_GLANCEABLES_TASKS_DROPDOWN_ACCESSIBLE_NAME, u"");
  init_params.expand_button_tooltip_id =
      IDS_GLANCEABLES_TASKS_EXPAND_BUTTON_EXPAND_TOOLTIP;
  init_params.collapse_button_tooltip_id =
      IDS_GLANCEABLES_TASKS_EXPAND_BUTTON_COLLAPSE_TOOLTIP;
  init_params.footer_title = l10n_util::GetStringUTF16(
      IDS_GLANCEABLES_LIST_FOOTER_SEE_ALL_TASKS_LABEL);
  init_params.footer_tooltip = l10n_util::GetStringUTF16(
      IDS_GLANCEABLES_TASKS_SEE_ALL_BUTTON_ACCESSIBLE_NAME);
  init_params.header_icon = &kGlanceablesTasksIcon;
  init_params.header_icon_tooltip_id =
      IDS_GLANCEABLES_TASKS_HEADER_ICON_ACCESSIBLE_NAME;
  return init_params;
}

api::TasksClient* GetTasksClient() {
  return Shell::Get()->glanceables_controller()->GetTasksClient();
}

// Returns a displayable last modified time for kCantUpdateList.
std::u16string GetLastUpdateTimeMessage(base::Time time) {
  const std::u16string time_of_day = base::TimeFormatTimeOfDay(time);
  const std::u16string relative_date =
      ui::TimeFormat::RelativeDate(time, nullptr);
  if (relative_date.empty()) {
    return l10n_util::GetStringFUTF16(
        IDS_GLANCEABLES_TASKS_ERROR_LAST_UPDATE_DATE_AND_TIME, time_of_day,
        base::TimeFormatShortDate(time));
  }

  const auto midnight_today = base::Time::Now().LocalMidnight();
  const auto midnight_tomorrow = midnight_today + base::Days(1);
  if (midnight_today <= time && time < midnight_tomorrow) {
    return l10n_util::GetStringFUTF16(
        IDS_GLANCEABLES_TASKS_ERROR_LAST_UPDATE_TIME, time_of_day);
  } else {
    return l10n_util::GetStringFUTF16(
        IDS_GLANCEABLES_TASKS_ERROR_LAST_UPDATE_DATE_AND_TIME, time_of_day,
        relative_date);
  }
}

class AddNewTaskButton : public views::LabelButton {
  METADATA_HEADER(AddNewTaskButton, views::LabelButton)
 public:
  explicit AddNewTaskButton(views::Button::PressedCallback callback)
      : views::LabelButton(
            std::move(callback),
            l10n_util::GetStringUTF16(
                IDS_GLANCEABLES_TASKS_ADD_NEW_TASK_BUTTON_LABEL)) {
    SetID(base::to_underlying(GlanceablesViewId::kTasksBubbleAddNewButton));
    SetImageModel(
        views::Button::ButtonState::STATE_NORMAL,
        ui::ImageModel::FromVectorIcon(kGlanceablesTasksAddNewTaskIcon,
                                       cros_tokens::kCrosSysPrimary));
    SetImageLabelSpacing(12);
    SetBorder(views::CreateEmptyBorder(gfx::Insets()));
    SetProperty(views::kMarginsKey, gfx::Insets::TLBR(4, 0, 8, 0));
    SetEnabledTextColorIds(cros_tokens::kCrosSysPrimary);
    label()->SetFontList(TypographyProvider::Get()->ResolveTypographyToken(
        TypographyToken::kCrosButton2));
    views::FocusRing::Get(this)->SetColorId(cros_tokens::kCrosSysFocusRing);
  }

  AddNewTaskButton(const AddNewTaskButton&) = delete;
  AddNewTaskButton& operator=(const AddNewTaskButton&) = delete;
  ~AddNewTaskButton() override = default;
};

BEGIN_METADATA(AddNewTaskButton)
END_METADATA

}  // namespace

GlanceablesTasksView::GlanceablesTasksView(
    const ui::ListModel<api::TaskList>* task_lists)
    : GlanceablesTimeManagementBubbleView(CreateInitParamsForTasks(task_lists)),
      shown_time_(base::Time::Now()) {
  // Caches the combobox model pointer for later model updates.
  tasks_combobox_model_ =
      static_cast<GlanceablesTasksComboboxModel*>(combobox_model());

  add_new_task_button_ = content_scroll_view()->contents()->AddChildViewAt(
      std::make_unique<AddNewTaskButton>(
          base::BindRepeating(&GlanceablesTasksView::AddNewTaskButtonPressed,
                              base::Unretained(this))),
      0);
  // Hide `add_new_task_button_` until the initial task list update.
  add_new_task_button_->SetVisible(false);

  const auto* active_task_list = GetActiveTaskList();
  auto* tasks =
      GetTasksClient()->GetCachedTasksInTaskList(active_task_list->id);
  if (tasks) {
    UpdateTasksInTaskList(active_task_list->id, active_task_list->title,
                          ListShownContext::kCachedList, /*fetch_success=*/true,
                          std::nullopt, tasks);
  } else {
    ScheduleUpdateTasks(ListShownContext::kInitialList);
  }
}

GlanceablesTasksView::~GlanceablesTasksView() {
  RecordTotalShowTimeForTasks(base::Time::Now() - shown_time_);
  if (first_task_list_shown_) {
    RecordTasksListChangeCount(tasks_list_change_count_);
    RecordNumberOfAddedTasks(added_tasks_, task_list_initially_empty_,
                             user_with_no_tasks_);
  }
}

void GlanceablesTasksView::Layout(PassKey) {
  LayoutSuperclass<GlanceablesTimeManagementBubbleView>(this);

  // Set the sentinel bounds to track the bottom of the last task view. Note
  // that the sentinel is the last view in `task_view_model_`.
  if (task_list_sentinel_) {
    if (task_view_model_.view_size() > 1) {
      auto* last_task =
          task_view_model_.view_at(task_view_model_.view_size() - 2);
      task_list_sentinel_->SetBoundsRect(gfx::Rect(
          gfx::Point(
              0, last_task->bounds().bottom() + kListViewBetweenChildSpacing),
          task_list_sentinel_->GetPreferredSize()));
    } else {
      task_list_sentinel_->SetBoundsRect(
          gfx::Rect(task_list_sentinel_->GetPreferredSize()));
    }
  }
}

gfx::Size GlanceablesTasksView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  if (running_resize_animation_.has_value() &&
      *running_resize_animation_ == ResizeAnimation::Type::kChildResize) {
    // The animation was implemented to ignore `available_size`. See b/351880846
    // for more detail.
    const gfx::Size base_preferred_size =
        views::FlexLayoutView::CalculatePreferredSize({});

    // If bottom of the task list is animating, offset the tasks view
    // preferred size so the tasks matches the animating bottom of the task
    // list. This reduces animation jankiness of the timing of the resize
    // animation gets slightly out of sync with the task views animations.
    const int sentinel_offset =
        task_list_sentinel_ && task_list_sentinel_->layer()
            ? task_list_sentinel_->layer()->transform().To2dTranslation().y()
            : 0;
    if (sentinel_offset != 0) {
      return gfx::Size(base_preferred_size.width(),
                       base_preferred_size.height() + sentinel_offset);
    }
  }

  return GlanceablesTimeManagementBubbleView::CalculatePreferredSize(
      available_size);
}

void GlanceablesTasksView::AnimationEnded(const gfx::Animation* animation) {
  running_resize_animation_.reset();
  GlanceablesTimeManagementBubbleView::AnimationEnded(animation);
}

void GlanceablesTasksView::CancelUpdates() {
  weak_ptr_factory_.InvalidateWeakPtrs();
}

void GlanceablesTasksView::UpdateTaskLists(
    const ui::ListModel<api::TaskList>* task_lists) {
  tasks_combobox_model_->UpdateTaskLists(task_lists);
  SetIsLoading(true);

  CHECK(tasks_combobox_model_->GetDefaultIndex().has_value());
  auto* active_task_list = tasks_combobox_model_->GetTaskListAt(
      tasks_combobox_model_->GetDefaultIndex().value());

  recreate_combobox_callback_ =
      base::BindOnce(&GlanceablesTasksView::CreateComboBoxView,
                     weak_ptr_factory_.GetWeakPtr());

  // Force fetch the updated tasks with the new active task list.
  GetTasksClient()->GetTasks(
      active_task_list->id, /*force_fetch=*/true,
      base::BindOnce(&GlanceablesTasksView::UpdateTasksInTaskList,
                     weak_ptr_factory_.GetWeakPtr(), active_task_list->id,
                     active_task_list->title, ListShownContext::kInitialList));
}

void GlanceablesTasksView::EndResizeAnimationForTest() {
  if (resize_animation_) {
    resize_animation_->End();
  }
}

void GlanceablesTasksView::OnHeaderIconPressed() {
  ActionButtonPressed(TasksLaunchSource::kHeaderButton,
                      GURL(kTasksManagementPage));
}

void GlanceablesTasksView::OnFooterButtonPressed() {
  ActionButtonPressed(TasksLaunchSource::kFooterButton,
                      GURL(kTasksManagementPage));
}

void GlanceablesTasksView::SelectedListChanged() {
  if (!glanceables_util::IsNetworkConnected()) {
    // If the network is disconnected, cancel the list change and show the error
    // message.
    ShowErrorMessageWithType(GlanceablesTasksErrorType::kCantLoadTasksNoNetwork,
                             ErrorMessageToast::ButtonActionType::kDismiss);
    combobox_view()->SetSelectedIndex(cached_selected_list_index_);
    return;
  }

  UpdateComboboxReplacementLabelText();

  weak_ptr_factory_.InvalidateWeakPtrs();
  tasks_requested_time_ = base::TimeTicks::Now();
  tasks_list_change_count_++;
  ScheduleUpdateTasks(ListShownContext::kUserSelectedList);
}

void GlanceablesTasksView::AnimateResize(ResizeAnimation::Type resize_type) {
  const int current_height = size().height();
  if (current_height == 0) {
    return;
  }

  // Child resize animation should not override the expand/collapse animation.
  if (resize_type == ResizeAnimation::Type::kChildResize &&
      running_resize_animation_.has_value() &&
      *running_resize_animation_ ==
          ResizeAnimation::Type::kContainerExpandStateChanged) {
    return;
  }

  resize_animation_.reset();
  running_resize_animation_.reset();

  if (!ui::ScopedAnimationDurationScaleMode::duration_multiplier()) {
    PreferredSizeChanged();
    return;
  }

  // Check if the available height is large enough for the preferred height, so
  // that the target height for the animation is correctly bounded.
  const views::SizeBound available_height =
      parent()->GetAvailableSize(this).height();
  const int preferred_height = GetPreferredSize().height();
  const int target_height =
      available_height.is_bounded()
          ? std::min(available_height.value(), preferred_height)
          : preferred_height;
  if (current_height == target_height) {
    return;
  }

  // If the scroll view is in overflow, and is expected to remain in overflow
  // after resizing, no need to animate the bubble size, as it's not actually
  // going to change.
  const int visible_scroll_height =
      content_scroll_view()->GetVisibleRect().height();
  if (resize_type == ResizeAnimation::Type::kChildResize &&
      content_scroll_view()->contents()->height() > visible_scroll_height &&
      content_scroll_view()->contents()->GetPreferredSize().height() >
          visible_scroll_height) {
    PreferredSizeChanged();
    return;
  }

  switch (resize_type) {
    case ResizeAnimation::Type::kContainerExpandStateChanged:
      SetUpResizeThroughputTracker(
          target_height > current_height
              ? kExpandAnimationSmoothnessHistogramName
              : kCollapseAnimationSmoothnessHistogramName);
      break;
    case ResizeAnimation::Type::kChildResize:
      SetUpResizeThroughputTracker(
          kChildResizingAnimationSmoothnessHistogramName);
      break;
  }
  running_resize_animation_ = resize_type;
  resize_animation_ = std::make_unique<ResizeAnimation>(
      current_height, target_height, this, resize_type);
  resize_animation_->Start();
}

void GlanceablesTasksView::AddNewTaskButtonPressed() {
  // TODO(b/301253574): make sure there is only one view is in `kEdit` state.
  items_container_view()->SetVisible(true);
  auto* const pending_new_task = items_container_view()->AddChildViewAt(
      CreateTaskView(GetActiveTaskList()->id, /*task=*/nullptr),
      /*index=*/0);
  task_view_model_.Add(pending_new_task, 0u);
  pending_new_task->UpdateTaskTitleViewForState(
      GlanceablesTaskView::TaskTitleViewState::kEdit);
  AnimateTaskViewVisibility(pending_new_task, true);
  HandleTaskViewStateChange(/*view_expanding=*/true);
  RecordUserStartedAddingTask();
}

std::unique_ptr<GlanceablesTaskView> GlanceablesTasksView::CreateTaskView(
    const std::string& task_list_id,
    const api::Task* task) {
  auto task_view = std::make_unique<GlanceablesTaskView>(
      task,
      base::BindRepeating(&GlanceablesTasksView::MarkTaskAsCompleted,
                          base::Unretained(this), task_list_id),
      base::BindRepeating(&GlanceablesTasksView::SaveTask,
                          base::Unretained(this), task_list_id),
      base::BindRepeating(
          &GlanceablesTasksView::ActionButtonPressed, base::Unretained(this),
          TasksLaunchSource::kEditInGoogleTasksButton,
          task && task->web_view_link.is_valid() ? task->web_view_link
                                                 : GURL(kTasksManagementPage)),
      base::BindRepeating(&GlanceablesTasksView::ShowErrorMessageWithType,
                          base::Unretained(this)));
  if (task) {
    // Start observing for state changes after setting the default task view
    // state.
    task_view->set_state_change_observer(
        base::BindRepeating(&GlanceablesTasksView::HandleTaskViewStateChange,
                            base::Unretained(this)));
  }
  return task_view;
}

void GlanceablesTasksView::ScheduleUpdateTasks(ListShownContext context) {
  if (!combobox_view()->GetSelectedIndex().has_value()) {
    return;
  }

  SetIsLoading(true);
  combobox_view()->GetViewAccessibility().SetDescription(u"");

  const auto* const active_task_list = GetActiveTaskList();
  tasks_combobox_model_->SaveLastSelectedTaskList(active_task_list->id);
  GetTasksClient()->GetTasks(
      active_task_list->id, /*force_fetch=*/true,
      base::BindOnce(&GlanceablesTasksView::UpdateTasksInTaskList,
                     weak_ptr_factory_.GetWeakPtr(), active_task_list->id,
                     active_task_list->title, context));
}

void GlanceablesTasksView::RetryUpdateTasks(ListShownContext context) {
  MaybeDismissErrorMessage();
  ScheduleUpdateTasks(context);
}

void GlanceablesTasksView::UpdateTasksInTaskList(
    const std::string& task_list_id,
    const std::string& task_list_title,
    ListShownContext context,
    bool fetch_success,
    std::optional<google_apis::ApiErrorCode> http_error,
    const ui::ListModel<api::Task>* tasks) {
  const gfx::Size old_preferred_size = GetPreferredSize();
  SetIsLoading(false);

  if (!recreate_combobox_callback_.is_null()) {
    std::move(recreate_combobox_callback_).Run();
  }

  if (!fetch_success) {
    switch (context) {
      case ListShownContext::kCachedList:
        // Cached list should always considered as successfully fetched.
        NOTREACHED();
      case ListShownContext::kInitialList: {
        if (GetTasksClient()->GetCachedTasksInTaskList(task_list_id)) {
          // Notify users the last updated time of the tasks with the cached
          // data.
          ShowErrorMessageWithType(
              GlanceablesTasksErrorType::kCantUpdateTasks,
              ErrorMessageToast::ButtonActionType::kReload);
        } else {
          // Notify users that the target list of tasks wasn't loaded and guide
          // them to retry.
          ShowErrorMessageWithType(
              GlanceablesTasksErrorType::kCantLoadTasks,
              ErrorMessageToast::ButtonActionType::kReload);
        }
        return;
      }
      case ListShownContext::kUserSelectedList:
        ShowErrorMessageWithType(GlanceablesTasksErrorType::kCantLoadTasks,
                                 ErrorMessageToast::ButtonActionType::kDismiss);
        combobox_view()->SetSelectedIndex(cached_selected_list_index_);
        return;
    }
  }

  // Discard the fetched tasks that is not shown now.
  if (task_list_id != GetActiveTaskList()->id) {
    return;
  }

  switch (context) {
    case ListShownContext::kCachedList:
      break;
    case ListShownContext::kInitialList:
      base::UmaHistogramCounts100(
          "Ash.Glanceables.TimeManagement.TasksCountInDefaultTaskList",
          tasks->item_count());
      break;
    case ListShownContext::kUserSelectedList:
      RecordNumberOfAddedTasks(added_tasks_, task_list_initially_empty_,
                               user_with_no_tasks_);
      added_tasks_ = 0;
      break;
  }

  add_new_task_button_->SetVisible(true);

  task_list_sentinel_ = nullptr;
  task_view_model_.Clear();
  cached_selected_list_index_ = combobox_view()->GetSelectedIndex();

  size_t num_tasks_shown = 0;
  user_with_no_tasks_ =
      tasks->item_count() == 0 && tasks_combobox_model_->GetItemCount() == 1;

  items_container_view()->SetVisible(false);
  for (const auto& task : *tasks) {
    if (task->completed) {
      continue;
    }

    if (num_tasks_shown < kMaximumTasks) {
      auto* task_view = items_container_view()->AddChildView(
          CreateTaskView(task_list_id, task.get()));
      task_view_model_.Add(task_view, task_view_model_.view_size());
      ++num_tasks_shown;
    }
  }

  items_container_view()->SetVisible(true);

  task_list_sentinel_ = AddChildView(std::make_unique<views::View>());
  task_list_sentinel_->SetProperty(views::kViewIgnoredByLayoutKey, true);
  // The view is hidden, and excluded from the layout - size is set to an
  // arbitrary non empty value.
  task_list_sentinel_->SetPreferredSize(gfx::Size(1, 1));
  task_list_sentinel_->SetPaintToLayer(ui::LAYER_NOT_DRAWN);
  task_list_sentinel_->layer()->SetFillsBoundsOpaquely(false);
  task_list_sentinel_->layer()->SetOpacity(0.0f);
  task_view_model_.Add(task_list_sentinel_.get(), task_view_model_.view_size());

  task_list_initially_empty_ = num_tasks_shown == 0;
  // Set `task_items_container_view()` to invisible if there is no task so that
  // the layout manager won't include it as a visible view.
  items_container_view()->SetVisible(
      !items_container_view()->children().empty());
  list_footer_view()->SetVisible(tasks->item_count() >= kMaximumTasks);
  expand_button()->UpdateCounter(tasks->item_count());

  combobox_view()->SetTooltipText(
      l10n_util::GetStringFUTF16(IDS_GLANCEABLES_TASKS_DROPDOWN_ACCESSIBLE_NAME,
                                 base::UTF8ToUTF16(task_list_title)));
  items_container_view()->GetViewAccessibility().SetName(
      l10n_util::GetStringFUTF16(
          IDS_GLANCEABLES_TASKS_SELECTED_LIST_ACCESSIBLE_NAME,
          base::UTF8ToUTF16(task_list_title)));
  items_container_view()->NotifyAccessibilityEvent(
      ax::mojom::Event::kChildrenChanged,
      /*send_native_event=*/true);

  if (old_preferred_size != GetPreferredSize()) {
    if (context == ListShownContext::kUserSelectedList) {
      AnimateResize(ResizeAnimation::Type::kChildResize);
    } else {
      PreferredSizeChanged();
    }
  }

  // Scroll the scroll view back to the top after the selected list is changed.
  content_scroll_view()->ScrollToOffset(gfx::PointF(0, 0));

  switch (context) {
    case ListShownContext::kCachedList:
      break;
    case ListShownContext::kInitialList: {
      auto* controller = Shell::Get()->glanceables_controller();
      RecordTasksInitialLoadTime(
          /*first_occurrence=*/controller->bubble_shown_count() == 1,
          base::TimeTicks::Now() - controller->last_bubble_show_time());
      first_task_list_shown_ = true;
      break;
    }
    case ListShownContext::kUserSelectedList:
      RecordActiveTaskListChanged();
      RecordTasksChangeLoadTime(base::TimeTicks::Now() - tasks_requested_time_);
      first_task_list_shown_ = true;
      break;
  }
}

void GlanceablesTasksView::HandleTaskViewStateChange(bool view_expanding) {
  // NOTE: A single event may cause one task view to expand, and another one to
  // collapse - the animation will use parameters for the event that comes in
  // later, which depends on how the views handle events.
  auto animation = views::AnimationBuilder();
  animation.Once().SetDuration(view_expanding
                                   ? kBubbleExpandAnimationDuration
                                   : kBubbleCollapseAnimationDuration);

  int target_offset = 0;
  for (size_t i = 0; i < task_view_model_.view_size(); ++i) {
    auto* child = task_view_model_.view_at(i);

    // Calculate the current apparent bounds for the view - the view transform
    // is set to animate it to its ideal bounds, which may differ from the
    // current view bounds if a layout is still pending.
    gfx::Rect current_bounds = task_view_model_.ideal_bounds(i);
    if (current_bounds.height() == 0) {
      current_bounds = child->bounds();
    }
    int current_offset =
        current_bounds.y() + child->layer()->transform().To2dTranslation().y();

    // Update the child transform to be relative to it's new target bounds,
    // which are expected to be set after an imminent layout pass.
    child->layer()->SetTransform(
        gfx::Transform::MakeTranslation(0, current_offset - target_offset));

    // Animate the view into the expected new position.
    if (current_offset != target_offset) {
      animation.GetCurrentSequence().SetTransform(child, gfx::Transform(),
                                                  kBubbleAnimationTweenType);
    }

    const gfx::Size preferred_size = child->GetPreferredSize();
    // Cache the bounds relative to which the transform was calculated, so they
    // can be used to update the view transform if a view state changes before
    // the imminent layout pass.
    task_view_model_.set_ideal_bounds(
        i, gfx::Rect(gfx::Point(current_bounds.x(), target_offset),
                     preferred_size));

    // Update the target offset for the next task view in the task list.
    target_offset += preferred_size.height() + kListViewBetweenChildSpacing;
  }

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

void GlanceablesTasksView::MarkTaskAsCompleted(const std::string& task_list_id,
                                               const std::string& task_id,
                                               bool completed) {
  GetTasksClient()->MarkAsCompleted(task_list_id, task_id, completed);
}

void GlanceablesTasksView::ActionButtonPressed(TasksLaunchSource source,
                                               const GURL& target_url) {
  if (user_with_no_tasks_) {
    RecordUserWithNoTasksRedictedToTasksUI();
  }
  RecordTasksLaunchSource(source);
  NewWindowDelegate::GetPrimary()->OpenUrl(
      target_url, NewWindowDelegate::OpenUrlFrom::kUserInteraction,
      NewWindowDelegate::Disposition::kNewForegroundTab);
}

void GlanceablesTasksView::SaveTask(
    const std::string& task_list_id,
    base::WeakPtr<GlanceablesTaskView> view,
    const std::string& task_id,
    const std::string& title,
    api::TasksClient::OnTaskSavedCallback callback) {
  if (task_id.empty()) {
    // Empty `task_id` means that the task has not yet been created. Verify that
    // this task has a non-empty title, otherwise just delete the `view` from
    // the scrollable container.
    if (title.empty() && view) {
      RecordTaskAdditionResult(TaskModificationResult::kCancelled);

      // Prevent editing the task view when `view` is waiting to be deleted.
      view->SetCanProcessEventsWithinSubtree(false);

      // Removing the task immediately may cause a crash when the task is saved
      // in response to the task title textfield losing focus, as it may result
      // in deleting focused view while the focus manager is handling focus
      // change to another view. b/324409607
      base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
          FROM_HERE, base::BindOnce(&GlanceablesTasksView::RemoveTaskView,
                                    weak_ptr_factory_.GetWeakPtr(), view));
      return;
    }

    if (view) {
      view->set_state_change_observer(
          base::BindRepeating(&GlanceablesTasksView::HandleTaskViewStateChange,
                              base::Unretained(this)));
    }
    ++added_tasks_;
    RecordTaskAdditionResult(TaskModificationResult::kCommitted);
  }

  SetIsLoading(true);

  auto* const client = GetTasksClient();
  auto on_task_saved = base::BindOnce(
      &GlanceablesTasksView::OnTaskSaved, weak_ptr_factory_.GetWeakPtr(),
      std::move(view), task_id, std::move(callback));
  if (task_id.empty()) {
    client->AddTask(task_list_id, title, std::move(on_task_saved));
  } else {
    client->UpdateTask(task_list_id, task_id, title, /*completed=*/false,
                       std::move(on_task_saved));
  }
}

void GlanceablesTasksView::OnTaskSaved(
    base::WeakPtr<GlanceablesTaskView> view,
    const std::string& task_id,
    api::TasksClient::OnTaskSavedCallback callback,
    google_apis::ApiErrorCode http_error,
    const api::Task* task) {
  if (!task) {
    ShowErrorMessageWithType(GlanceablesTasksErrorType::kCantUpdateTitle,
                             ErrorMessageToast::ButtonActionType::kDismiss);
    if (task_id.empty() && view) {
      // Empty `task_id` means that the task has not yet been created. Delete
      // the corresponding `view` from the scrollable container in case of
      // error.
      RemoveTaskView(view);
    }
  } else if (task->title.empty()) {
    RemoveTaskView(view);
  }
  SetIsLoading(false);
  std::move(callback).Run(http_error, task);

  const size_t tasks_count = items_container_view()->children().size();
  items_container_view()->SetVisible(tasks_count > 0);
  expand_button()->UpdateCounter(tasks_count);
  list_footer_view()->SetVisible(tasks_count >= kMaximumTasks);
}

const api::TaskList* GlanceablesTasksView::GetActiveTaskList() const {
  return tasks_combobox_model_->GetTaskListAt(GetComboboxSelectedIndex());
}

void GlanceablesTasksView::ShowErrorMessageWithType(
    GlanceablesTasksErrorType error_type,
    ErrorMessageToast::ButtonActionType button_type) {
  views::Button::PressedCallback callback;
  switch (button_type) {
    case ErrorMessageToast::ButtonActionType::kDismiss:
      callback =
          base::BindRepeating(&GlanceablesTasksView::MaybeDismissErrorMessage,
                              base::Unretained(this));
      break;
    case ErrorMessageToast::ButtonActionType::kReload:
      // This only used to reload the current shown list.
      callback = base::BindRepeating(&GlanceablesTasksView::RetryUpdateTasks,
                                     base::Unretained(this),
                                     ListShownContext::kInitialList);
      break;
  }
  ShowErrorMessage(GetErrorString(error_type), std::move(callback),
                   button_type);
}

std::u16string GlanceablesTasksView::GetErrorString(
    GlanceablesTasksErrorType error_type) const {
  switch (error_type) {
    case GlanceablesTasksErrorType::kCantUpdateTasks: {
      auto last_modified_time =
          GetTasksClient()->GetTasksLastUpdateTime(GetActiveTaskList()->id);
      if (!last_modified_time.has_value()) {
        return l10n_util::GetStringUTF16(
            IDS_GLANCEABLES_TASKS_ERROR_LOAD_ITEMS_FAILED);
      }
      return GetLastUpdateTimeMessage(last_modified_time.value());
    }
    case GlanceablesTasksErrorType::kCantLoadTasks:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_LOAD_ITEMS_FAILED);
    case GlanceablesTasksErrorType::kCantLoadTasksNoNetwork:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_LOAD_ITEMS_FAILED_WHILE_OFFLINE);
    case GlanceablesTasksErrorType::kCantMarkComplete:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_MARK_COMPLETE_FAILED);
    case GlanceablesTasksErrorType::kCantMarkCompleteNoNetwork:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_MARK_COMPLETE_FAILED_WHILE_OFFLINE);
    case GlanceablesTasksErrorType::kCantUpdateTitle:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_EDIT_TASK_FAILED);
    case GlanceablesTasksErrorType::kCantUpdateTitleNoNetwork:
      return l10n_util::GetStringUTF16(
          IDS_GLANCEABLES_TASKS_ERROR_EDIT_TASK_FAILED_WHILE_OFFLINE);
  }
}

void GlanceablesTasksView::RemoveTaskView(
    base::WeakPtr<GlanceablesTaskView> task_view) {
  if (!task_view) {
    return;
  }

  if (task_view->Contains(GetFocusManager()->GetFocusedView())) {
    add_new_task_button_->RequestFocus();
  }

  AnimateTaskViewVisibility(task_view.get(), false);

  auto index_in_model = task_view_model_.GetIndexOfView(task_view.get());
  if (index_in_model) {
    task_view_model_.Remove(*index_in_model);
  }
  items_container_view()->RemoveChildViewT(task_view.get());
  HandleTaskViewStateChange(/*view_expanding=*/false);

  items_container_view()->SetVisible(
      !items_container_view()->children().empty());
}

void GlanceablesTasksView::SetIsLoading(bool is_loading) {
  progress_bar()->UpdateProgressBarVisibility(is_loading);

  // Disable all events in the subtree if the data fetch is ongoing.
  SetCanProcessEventsWithinSubtree(!is_loading);
}

void GlanceablesTasksView::AnimateTaskViewVisibility(views::View* task,
                                                     bool visible) {
  if (!visible) {
    animating_task_view_layer_ = task->RecreateLayer();
  } else {
    task->layer()->SetOpacity(animating_task_view_layer_
                                  ? animating_task_view_layer_->opacity()
                                  : 0.0f);
    animating_task_view_layer_.reset();
  }

  task->SetVisible(visible);

  // Animate the transform back to the identity transform.
  views::AnimationBuilder()
      .OnEnded(
          base::BindOnce(&GlanceablesTasksView::OnTaskViewAnimationCompleted,
                         weak_ptr_factory_.GetWeakPtr()))
      .OnAborted(
          base::BindOnce(&GlanceablesTasksView::OnTaskViewAnimationCompleted,
                         weak_ptr_factory_.GetWeakPtr()))
      .Once()
      .At(visible ? base::Milliseconds(50) : base::TimeDelta())
      .SetOpacity(visible ? task->layer() : animating_task_view_layer_.get(),
                  visible ? 1.0f : 0.0f, gfx::Tween::LINEAR)
      .SetDuration(base::Milliseconds(visible ? 100 : 50));
}

void GlanceablesTasksView::OnTaskViewAnimationCompleted() {
  animating_task_view_layer_.reset();
}

BEGIN_METADATA(GlanceablesTasksView)
END_METADATA

}  // namespace ash