| // 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/classroom/glanceables_classroom_student_view.h" |
| |
| #include <algorithm> |
| #include <array> |
| #include <memory> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "ash/glanceables/classroom/glanceables_classroom_client.h" |
| #include "ash/glanceables/classroom/glanceables_classroom_item_view.h" |
| #include "ash/glanceables/classroom/glanceables_classroom_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_view_id.h" |
| #include "ash/glanceables/glanceables_controller.h" |
| #include "ash/glanceables/glanceables_metrics.h" |
| #include "ash/public/cpp/new_window_delegate.h" |
| #include "ash/resources/vector_icons/vector_icons.h" |
| #include "ash/session/session_controller_impl.h" |
| #include "ash/shell.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "ash/style/combobox.h" |
| #include "ash/style/error_message_toast.h" |
| #include "ash/style/typography.h" |
| #include "base/check.h" |
| #include "base/functional/bind.h" |
| #include "base/metrics/user_metrics.h" |
| #include "base/time/time.h" |
| #include "components/prefs/pref_registry_simple.h" |
| #include "components/prefs/pref_service.h" |
| #include "ui/accessibility/ax_enums.mojom-shared.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/layer.h" |
| #include "ui/gfx/geometry/insets.h" |
| #include "ui/gfx/geometry/size.h" |
| #include "ui/gfx/scoped_animation_duration_scale_mode.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/controls/label.h" |
| #include "ui/views/layout/layout_types.h" |
| #include "ui/views/metadata/view_factory.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| #include "url/gurl.h" |
| |
| namespace ash { |
| namespace { |
| |
| // Helps to map `combobox_view_` selected index to the corresponding |
| // `StudentAssignmentsListType` value. |
| constexpr std::array<StudentAssignmentsListType, 4> |
| kStudentAssignmentsListTypeOrdered = { |
| StudentAssignmentsListType::kAssigned, |
| StudentAssignmentsListType::kNoDueDate, |
| StudentAssignmentsListType::kMissing, |
| StudentAssignmentsListType::kDone}; |
| |
| constexpr auto kStudentAssignmentsListTypeToLabel = |
| base::MakeFixedFlatMap<StudentAssignmentsListType, int>( |
| {{StudentAssignmentsListType::kAssigned, |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_DUE_SOON_LIST_NAME}, |
| {StudentAssignmentsListType::kNoDueDate, |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_NO_DUE_DATE_LIST_NAME}, |
| {StudentAssignmentsListType::kMissing, |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_MISSING_LIST_NAME}, |
| {StudentAssignmentsListType::kDone, |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_DONE_LIST_NAME}}); |
| |
| constexpr char kClassroomHomePage[] = "https://classroom.google.com/u/0/h"; |
| constexpr char kClassroomWebUIAssignedUrl[] = |
| "https://classroom.google.com/u/0/a/not-turned-in/all"; |
| constexpr char kClassroomWebUIMissingUrl[] = |
| "https://classroom.google.com/u/0/a/missing/all"; |
| constexpr char kClassroomWebUIDoneUrl[] = |
| "https://classroom.google.com/u/0/a/turned-in/all"; |
| |
| constexpr char kLastSelectedAssignmentsListPref[] = |
| "ash.glanceables.classroom.student.last_selected_assignments_list"; |
| |
| constexpr char kExpandAnimationSmoothnessHistogramName[] = |
| "Ash.Glanceables.TimeManagement.Classroom.Expand.AnimationSmoothness"; |
| constexpr char kCollapseAnimationSmoothnessHistogramName[] = |
| "Ash.Glanceables.TimeManagement.Classroom.Collapse.AnimationSmoothness"; |
| |
| constexpr size_t kMaxAssignments = 100; |
| |
| constexpr auto kEmptyListLabelMargins = gfx::Insets::TLBR(24, 0, 32, 0); |
| constexpr auto kFooterMargins = gfx::Insets::TLBR(12, 2, 0, 0); |
| |
| std::u16string GetAssignmentListName(size_t index) { |
| CHECK(index >= 0 || index < kStudentAssignmentsListTypeOrdered.size()); |
| |
| const auto iter = kStudentAssignmentsListTypeToLabel.find( |
| kStudentAssignmentsListTypeOrdered[index]); |
| CHECK(iter != kStudentAssignmentsListTypeToLabel.end()); |
| |
| return l10n_util::GetStringUTF16(iter->second); |
| } |
| |
| class ClassroomStudentComboboxModel : public ui::ComboboxModel { |
| public: |
| ClassroomStudentComboboxModel() = default; |
| ClassroomStudentComboboxModel(const ClassroomStudentComboboxModel&) = delete; |
| ClassroomStudentComboboxModel& operator=( |
| const ClassroomStudentComboboxModel&) = delete; |
| ~ClassroomStudentComboboxModel() override = default; |
| |
| size_t GetItemCount() const override { |
| return kStudentAssignmentsListTypeOrdered.size(); |
| } |
| |
| std::u16string GetItemAt(size_t index) const override { |
| return GetAssignmentListName(index); |
| } |
| |
| std::optional<size_t> GetDefaultIndex() const override { |
| const auto selected_list_type = static_cast<StudentAssignmentsListType>( |
| Shell::Get()->session_controller()->GetActivePrefService()->GetInteger( |
| kLastSelectedAssignmentsListPref)); |
| const auto iter = std::ranges::find(kStudentAssignmentsListTypeOrdered, |
| selected_list_type); |
| return iter != kStudentAssignmentsListTypeOrdered.end() |
| ? iter - kStudentAssignmentsListTypeOrdered.begin() |
| : 0; |
| } |
| }; |
| |
| GlanceablesClassroomStudentView::InitParams CreateInitParamsForClassroom() { |
| GlanceablesClassroomStudentView::InitParams init_params; |
| init_params.context = GlanceablesClassroomStudentView::Context::kClassroom; |
| init_params.combobox_model = |
| std::make_unique<ClassroomStudentComboboxModel>(); |
| init_params.combobox_tooltip = l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_DROPDOWN_ACCESSIBLE_NAME); |
| init_params.expand_button_tooltip_id = |
| IDS_GLANCEABLES_CLASSROOM_EXPAND_BUTTON_EXPAND_TOOLTIP; |
| init_params.collapse_button_tooltip_id = |
| IDS_GLANCEABLES_CLASSROOM_EXPAND_BUTTON_COLLAPSE_TOOLTIP; |
| init_params.footer_title = l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_LIST_FOOTER_SEE_ALL_ASSIGNMENTS_LABEL); |
| init_params.footer_tooltip = l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_SEE_ALL_BUTTON_ACCESSIBLE_NAME); |
| init_params.header_icon = &kGlanceablesClassroomIcon; |
| init_params.header_icon_tooltip_id = |
| IDS_GLANCEABLES_CLASSROOM_HEADER_ICON_ACCESSIBLE_NAME; |
| return init_params; |
| } |
| |
| } // namespace |
| |
| GlanceablesClassroomStudentView::GlanceablesClassroomStudentView() |
| : GlanceablesTimeManagementBubbleView(CreateInitParamsForClassroom()), |
| shown_time_(base::Time::Now()) { |
| const auto* const typography_provider = TypographyProvider::Get(); |
| empty_list_label_ = content_scroll_view()->contents()->AddChildView( |
| views::Builder<views::Label>() |
| .SetProperty(views::kMarginsKey, kEmptyListLabelMargins) |
| .SetEnabledColor(cros_tokens::kCrosSysOnSurface) |
| .SetFontList(typography_provider->ResolveTypographyToken( |
| TypographyToken::kCrosButton2)) |
| .SetLineHeight(typography_provider->ResolveLineHeight( |
| TypographyToken::kCrosButton2)) |
| .SetID(std::to_underlying( |
| GlanceablesViewId::kClassroomBubbleEmptyListLabel)) |
| .Build()); |
| |
| SelectedAssignmentListChanged(/*initial_update=*/true); |
| } |
| |
| GlanceablesClassroomStudentView::~GlanceablesClassroomStudentView() { |
| if (list_shown_start_time_.has_value()) { |
| RecordStudentAssignmentListShowTime( |
| selected_list_type_, |
| base::TimeTicks::Now() - list_shown_start_time_.value(), |
| /*default_list=*/selected_list_change_count_ == 0); |
| } |
| if (first_assignment_list_shown_) { |
| RecordStudentSelectedListChangeCount(selected_list_change_count_); |
| } |
| |
| RecordTotalShowTimeForClassroom(base::Time::Now() - shown_time_); |
| } |
| |
| // static |
| void GlanceablesClassroomStudentView::RegisterUserProfilePrefs( |
| PrefRegistrySimple* registry) { |
| registry->RegisterIntegerPref( |
| kLastSelectedAssignmentsListPref, |
| std::to_underlying(StudentAssignmentsListType::kAssigned)); |
| } |
| |
| // static |
| void GlanceablesClassroomStudentView::ClearUserStatePrefs( |
| PrefService* pref_service) { |
| pref_service->ClearPref(kLastSelectedAssignmentsListPref); |
| } |
| |
| void GlanceablesClassroomStudentView::CancelUpdates() { |
| weak_ptr_factory_.InvalidateWeakPtrs(); |
| } |
| |
| void GlanceablesClassroomStudentView::OnHeaderIconPressed() { |
| RecordClassroomHeaderIconPressed(); |
| |
| OpenUrl(GURL(kClassroomHomePage)); |
| } |
| |
| void GlanceablesClassroomStudentView::OnFooterButtonPressed() { |
| base::RecordAction( |
| base::UserMetricsAction("Glanceables_Classroom_SeeAllPressed")); |
| CHECK(combobox_view()->GetSelectedIndex()); |
| |
| switch (selected_list_type_) { |
| case StudentAssignmentsListType::kAssigned: |
| case StudentAssignmentsListType::kNoDueDate: |
| return OpenUrl(GURL(kClassroomWebUIAssignedUrl)); |
| case StudentAssignmentsListType::kMissing: |
| return OpenUrl(GURL(kClassroomWebUIMissingUrl)); |
| case StudentAssignmentsListType::kDone: |
| return OpenUrl(GURL(kClassroomWebUIDoneUrl)); |
| } |
| } |
| |
| void GlanceablesClassroomStudentView::SelectedListChanged() { |
| SelectedAssignmentListChanged(/*initial_update=*/false); |
| } |
| |
| void GlanceablesClassroomStudentView::AnimateResize( |
| ResizeAnimation::Type resize_type) { |
| const int current_height = size().height(); |
| if (current_height == 0) { |
| return; |
| } |
| resize_animation_.reset(); |
| |
| if (!gfx::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; |
| } |
| |
| SetUpResizeThroughputTracker(target_height > current_height |
| ? kExpandAnimationSmoothnessHistogramName |
| : kCollapseAnimationSmoothnessHistogramName); |
| resize_animation_ = std::make_unique<ResizeAnimation>( |
| current_height, target_height, this, |
| ResizeAnimation::Type::kContainerExpandStateChanged); |
| resize_animation_->Start(); |
| } |
| |
| void GlanceablesClassroomStudentView::OpenUrl(const GURL& url) const { |
| NewWindowDelegate::GetInstance()->OpenUrl( |
| url, NewWindowDelegate::OpenUrlFrom::kUserInteraction, |
| NewWindowDelegate::Disposition::kNewForegroundTab); |
| } |
| |
| void GlanceablesClassroomStudentView::OnItemViewPressed( |
| bool initial_list_selected, |
| const GURL& url) { |
| RecordStudentAssignmentPressed(/*default_list=*/initial_list_selected); |
| |
| OpenUrl(url); |
| } |
| |
| void GlanceablesClassroomStudentView::SelectedAssignmentListChanged( |
| bool initial_update) { |
| auto* const client = |
| Shell::Get()->glanceables_controller()->GetClassroomClient(); |
| if (!client) { |
| // Hide this bubble when no classroom client exists. |
| SetVisible(false); |
| return; |
| } |
| |
| const auto prev_selected_list_type = selected_list_type_; |
| const auto selected_index = GetComboboxSelectedIndex(); |
| CHECK(selected_index >= 0 || |
| selected_index < kStudentAssignmentsListTypeOrdered.size()); |
| selected_list_type_ = kStudentAssignmentsListTypeOrdered[selected_index]; |
| |
| UpdateComboboxReplacementLabelText(); |
| |
| if (!initial_update) { |
| base::RecordAction( |
| base::UserMetricsAction("Glanceables_Classroom_SelectedListChanged")); |
| if (list_shown_start_time_.has_value()) { |
| RecordStudentAssignmentListShowTime( |
| prev_selected_list_type, |
| base::TimeTicks::Now() - list_shown_start_time_.value(), |
| /*default_list=*/selected_list_change_count_ == 0); |
| } |
| RecordStudentAssignmentListSelected(selected_list_type_); |
| selected_list_change_count_++; |
| } |
| list_shown_start_time_.reset(); |
| |
| Shell::Get()->session_controller()->GetActivePrefService()->SetInteger( |
| kLastSelectedAssignmentsListPref, |
| std::to_underlying(selected_list_type_)); |
| |
| // Cancel any old pending assignment requests. |
| CancelUpdates(); |
| |
| assignments_requested_time_ = base::TimeTicks::Now(); |
| progress_bar()->UpdateProgressBarVisibility(/*visible=*/true); |
| combobox_view()->GetViewAccessibility().SetDescription(u""); |
| |
| auto callback = |
| base::BindOnce(&GlanceablesClassroomStudentView::OnGetAssignments, |
| weak_ptr_factory_.GetWeakPtr(), |
| GetAssignmentListName(selected_index), initial_update); |
| switch (selected_list_type_) { |
| case StudentAssignmentsListType::kAssigned: |
| empty_list_label_->SetText(l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_EMPTY_ITEM_DUE_LIST)); |
| return client->GetStudentAssignmentsWithApproachingDueDate( |
| std::move(callback)); |
| case StudentAssignmentsListType::kNoDueDate: |
| empty_list_label_->SetText(l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_EMPTY_ITEM_DUE_LIST)); |
| return client->GetStudentAssignmentsWithoutDueDate(std::move(callback)); |
| case StudentAssignmentsListType::kMissing: |
| empty_list_label_->SetText(l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_EMPTY_ITEM_MISSING_LIST)); |
| return client->GetStudentAssignmentsWithMissedDueDate( |
| std::move(callback)); |
| case StudentAssignmentsListType::kDone: |
| empty_list_label_->SetText(l10n_util::GetStringUTF16( |
| IDS_GLANCEABLES_CLASSROOM_STUDENT_EMPTY_ITEM_DONE_LIST)); |
| return client->GetCompletedStudentAssignments(std::move(callback)); |
| } |
| } |
| |
| void GlanceablesClassroomStudentView::OnGetAssignments( |
| const std::u16string& list_name, |
| bool initial_update, |
| bool success, |
| std::vector<std::unique_ptr<GlanceablesClassroomAssignment>> assignments) { |
| const gfx::Size old_preferred_size = GetPreferredSize(); |
| |
| progress_bar()->UpdateProgressBarVisibility(/*visible=*/false); |
| |
| items_container_view()->RemoveAllChildViews(); |
| total_assignments_ = assignments.size(); |
| |
| const size_t num_assignments = std::min(kMaxAssignments, assignments.size()); |
| for (size_t i = 0; i < num_assignments; ++i) { |
| items_container_view()->AddChildView( |
| std::make_unique<GlanceablesClassroomItemView>( |
| assignments[i].get(), |
| base::BindRepeating( |
| &GlanceablesClassroomStudentView::OnItemViewPressed, |
| base::Unretained(this), initial_update, assignments[i]->link))); |
| } |
| const size_t shown_assignments = items_container_view()->children().size(); |
| expand_button()->UpdateCounter(shown_assignments); |
| |
| const bool is_list_empty = shown_assignments == 0; |
| empty_list_label_->SetVisible(is_list_empty); |
| |
| bool should_show_footer_view; |
| should_show_footer_view = assignments.size() >= kMaxAssignments; |
| list_footer_view()->SetVisible(should_show_footer_view); |
| list_footer_view()->SetProperty(views::kMarginsKey, kFooterMargins); |
| |
| items_container_view()->GetViewAccessibility().SetName( |
| l10n_util::GetStringFUTF16( |
| IDS_GLANCEABLES_CLASSROOM_SELECTED_LIST_ACCESSIBLE_NAME, list_name)); |
| items_container_view()->NotifyAccessibilityEventDeprecated( |
| ax::mojom::Event::kChildrenChanged, |
| /*send_native_event=*/true); |
| |
| if (old_preferred_size != GetPreferredSize()) { |
| PreferredSizeChanged(); |
| |
| if (!initial_update) { |
| GetWidget()->LayoutRootViewIfNecessary(); |
| ScrollViewToVisible(); |
| } |
| } |
| |
| // Reset the position of the scroll view after the new data is fetched. |
| content_scroll_view()->ScrollToOffset(gfx::PointF(0, 0)); |
| |
| auto* controller = Shell::Get()->glanceables_controller(); |
| |
| if (initial_update) { |
| RecordClassromInitialLoadTime( |
| /*first_occurrence=*/controller->bubble_shown_count() == 1, |
| base::TimeTicks::Now() - controller->last_bubble_show_time()); |
| } else { |
| RecordClassroomChangeLoadTime( |
| success, base::TimeTicks::Now() - assignments_requested_time_); |
| } |
| |
| list_shown_start_time_ = base::TimeTicks::Now(); |
| first_assignment_list_shown_ = true; |
| |
| if (success) { |
| MaybeDismissErrorMessage(); |
| } else { |
| ShowErrorMessage( |
| l10n_util::GetStringUTF16(IDS_GLANCEABLES_CLASSROOM_FETCH_ERROR), |
| base::BindRepeating( |
| &GlanceablesClassroomStudentView::MaybeDismissErrorMessage, |
| base::Unretained(this)), |
| ErrorMessageToast::ButtonActionType::kDismiss); |
| } |
| } |
| |
| BEGIN_METADATA(GlanceablesClassroomStudentView) |
| END_METADATA |
| |
| } // namespace ash |