From 33e54d34572aa25f4202f03518958375a691112f Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:35:34 -0500 Subject: [PATCH] feat: lazy load activities (#5828) * feat: lazy load activities * updates --- lib/pages/chat_details/chat_details.dart | 13 +- .../activity_summaries_provider.dart | 6 +- .../course_topics/course_topic_model.dart | 12 ++ .../courses/course_plan_builder.dart | 32 --- .../activity_card_placeholder.dart | 43 ++++ .../course_settings/course_settings.dart | 195 +----------------- .../topic_activities_list.dart | 160 ++++++++++++++ 7 files changed, 234 insertions(+), 227 deletions(-) create mode 100644 lib/pangea/course_settings/activity_card_placeholder.dart create mode 100644 lib/pangea/course_settings/topic_activities_list.dart diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index 46f59266b..54b474fac 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -54,7 +54,7 @@ class ChatDetails extends StatefulWidget { // class ChatDetailsController extends State { class ChatDetailsController extends State with ActivitySummariesProvider, CoursePlanProvider, ChatDownloadProvider { - bool loadingActivities = true; + bool loadingCourseInfo = true; bool loadingCourseSummary = true; // listen to language updates to refresh course info @@ -344,18 +344,17 @@ class ChatDetailsController extends State course = null; loadingCourse = false; loadingTopics = false; - loadingActivities = false; + loadingCourseInfo = false; }); return; } - if (mounted) setState(() => loadingActivities = true); + if (mounted) setState(() => loadingCourseInfo = true); await loadCourse(room.coursePlan!.uuid); - if (course != null) { - if (mounted) await loadTopics(); - if (mounted) await loadAllActivities(); + if (course != null && mounted) { + await loadTopics(); } - if (mounted) setState(() => loadingActivities = false); + if (mounted) setState(() => loadingCourseInfo = false); } Future _loadSummaries() async { diff --git a/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart b/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart index 2780905b1..89a75e6ec 100644 --- a/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart +++ b/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart @@ -167,8 +167,8 @@ mixin ActivitySummariesProvider on State { return completedTopicActivities.length >= activitiesToCompleteOverride; } - final numTwoPersonActivities = topic.loadedActivities.values - .where((a) => a.req.numberOfParticipants <= 2) + final numTwoPersonActivities = topic.activityRoleCounts.entries + .where((e) => e.value <= 2) .length; return completedTopicActivities.length >= numTwoPersonActivities; @@ -187,8 +187,6 @@ mixin ActivitySummariesProvider on State { final topicId = course.topicIds[i]; final topic = course.loadedTopics[topicId]; if (topic == null) continue; - if (!topic.activityListComplete) continue; - if (!_hasCompletedTopic(userID, topic, activitiesToCompleteOverride) && topic.activityIds.isNotEmpty) { return topicId; diff --git a/lib/pangea/course_plans/course_topics/course_topic_model.dart b/lib/pangea/course_plans/course_topics/course_topic_model.dart index 6920eb67e..af6361683 100644 --- a/lib/pangea/course_plans/course_topics/course_topic_model.dart +++ b/lib/pangea/course_plans/course_topics/course_topic_model.dart @@ -17,6 +17,7 @@ class CourseTopicModel { final String uuid; final List locationIds; final List activityIds; + final Map activityRoleCounts; CourseTopicModel({ required this.title, @@ -24,6 +25,7 @@ class CourseTopicModel { required this.uuid, required this.activityIds, required this.locationIds, + required this.activityRoleCounts, }); bool get locationListComplete => @@ -103,12 +105,21 @@ class CourseTopicModel { json['location_ids'] as List? ?? json['locationIds'] as List?; + final activityRoleCountsEntry = json['activity_role_counts']; + Map activityRoleCounts = {}; + if (activityRoleCountsEntry != null) { + activityRoleCounts = Map.from( + activityRoleCountsEntry, + ).map((key, value) => MapEntry(key, value as int)); + } + return CourseTopicModel( title: json['title'] as String, description: json['description'] as String, uuid: json['uuid'] as String, activityIds: activityIdsEntry?.map((e) => e as String).toList() ?? [], locationIds: locationIdsEntry?.map((e) => e as String).toList() ?? [], + activityRoleCounts: activityRoleCounts, ); } @@ -120,6 +131,7 @@ class CourseTopicModel { 'uuid': uuid, 'activity_ids': activityIds, 'location_ids': locationIds, + 'activity_role_counts': activityRoleCounts, }; } } diff --git a/lib/pangea/course_plans/courses/course_plan_builder.dart b/lib/pangea/course_plans/courses/course_plan_builder.dart index b08ca0ee2..ea73ab01d 100644 --- a/lib/pangea/course_plans/courses/course_plan_builder.dart +++ b/lib/pangea/course_plans/courses/course_plan_builder.dart @@ -15,8 +15,6 @@ mixin CoursePlanProvider on State { bool loadingTopics = false; Object? topicError; - Map activityErrors = {}; - CoursePlanModel? course; Future _initStorage() async { @@ -87,34 +85,4 @@ mixin CoursePlanProvider on State { } await Future.wait(futures); } - - Future loadActivity(String topicId) async { - setState(() { - activityErrors[topicId] = null; - }); - - try { - final topic = course?.loadedTopics[topicId]; - if (topic == null) { - throw Exception("Topic is null"); - } - await topic.fetchActivities(); - } catch (e) { - activityErrors[topicId] = e; - } finally { - if (mounted) { - setState(() {}); - } - } - } - - Future loadAllActivities() async { - if (course == null) return; - - final futures = []; - for (final topicId in course!.topicIds) { - futures.add(loadActivity(topicId)); - } - await Future.wait(futures); - } } diff --git a/lib/pangea/course_settings/activity_card_placeholder.dart b/lib/pangea/course_settings/activity_card_placeholder.dart new file mode 100644 index 000000000..1f09e7528 --- /dev/null +++ b/lib/pangea/course_settings/activity_card_placeholder.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:shimmer/shimmer.dart'; + +import 'package:fluffychat/config/themes.dart'; + +class ActivityCardPlaceholder extends StatelessWidget { + final int activityCount; + + const ActivityCardPlaceholder({super.key, required this.activityCount}); + + @override + Widget build(BuildContext context) { + final int shimmerCount = activityCount; + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + return SizedBox( + height: isColumnMode ? 290.0 : 210.0, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shimmerCount, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 24.0), + child: Shimmer.fromColors( + baseColor: theme.colorScheme.primary.withAlpha(20), + highlightColor: theme.colorScheme.primary.withAlpha(50), + child: Container( + width: isColumnMode ? 160.0 : 120.0, + height: isColumnMode ? 280.0 : 200.0, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12.0), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/course_settings/course_settings.dart b/lib/pangea/course_settings/course_settings.dart index 2d61c8c56..7c62eb08d 100644 --- a/lib/pangea/course_settings/course_settings.dart +++ b/lib/pangea/course_settings/course_settings.dart @@ -3,31 +3,22 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; -import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart'; import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/course_settings/pin_clipper.dart'; +import 'package:fluffychat/pangea/course_settings/topic_activities_list.dart'; import 'package:fluffychat/pangea/course_settings/topic_participant_list.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; import 'package:fluffychat/widgets/matrix.dart'; class CourseSettings extends StatelessWidget { - // final Room room; - - // /// The course ID to load, from the course plan event in the room. - // /// Separate from the room to trigger didUpdateWidget on change - // final String? courseId; final ChatDetailsController controller; const CourseSettings({super.key, required this.controller}); @@ -124,7 +115,7 @@ class CourseSettings extends StatelessWidget { ? null : controller.course!.topicIds.indexOf(activeTopicId); - final Map> userTopics = controller.loadingActivities + final Map> userTopics = controller.loadingCourseInfo ? {} : controller.topicsToUsers( room, @@ -145,13 +136,11 @@ class CourseSettings extends StatelessWidget { } final usersInTopic = userTopics[topicId] ?? []; - final activityError = controller.activityErrors[topicId]; final bool locked = !teacherMode && (topicIndex == null ? false : index > topicIndex); - final disabled = - locked || controller.loadingActivities || activityError != null; + final disabled = locked || controller.loadingCourseInfo; return AbsorbPointer( absorbing: disabled, @@ -251,26 +240,14 @@ class CourseSettings extends StatelessWidget { }, ), if (!locked) - controller.loadingActivities - ? ActivityCardPlaceholder( - activityCount: topic.activityIds.length, - ) - : activityError != null - ? ErrorIndicator( - message: L10n.of(context).oopsSomethingWentWrong, - ) - : topic.loadedActivities.isNotEmpty - ? SizedBox( - height: isColumnMode ? 290.0 : 210.0, - child: TopicActivitiesList( - room: room, - activities: topic.loadedActivities, - loading: controller.loadingCourseSummary, - hasCompletedActivity: - controller.hasCompletedActivity, - ), - ) - : const SizedBox(), + SizedBox( + height: isColumnMode ? 290.0 : 210.0, + child: TopicActivitiesList( + room: room, + topic: topic, + hasCompletedActivity: controller.hasCompletedActivity, + ), + ), ], ), ), @@ -281,153 +258,3 @@ class CourseSettings extends StatelessWidget { ); } } - -class ActivityCardPlaceholder extends StatelessWidget { - final int activityCount; - - const ActivityCardPlaceholder({super.key, required this.activityCount}); - - @override - Widget build(BuildContext context) { - final int shimmerCount = activityCount; - final theme = Theme.of(context); - final isColumnMode = FluffyThemes.isColumnMode(context); - - return SizedBox( - height: isColumnMode ? 290.0 : 210.0, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: shimmerCount, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.only(right: 24.0), - child: Shimmer.fromColors( - baseColor: theme.colorScheme.primary.withAlpha(20), - highlightColor: theme.colorScheme.primary.withAlpha(50), - child: Container( - width: isColumnMode ? 160.0 : 120.0, - height: isColumnMode ? 280.0 : 200.0, - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12.0), - ), - ), - ), - ); - }, - ), - ); - } -} - -class TopicActivitiesList extends StatefulWidget { - final Room room; - final Map activities; - final bool loading; - final bool Function(String userId, String activityId) hasCompletedActivity; - - const TopicActivitiesList({ - super.key, - required this.room, - required this.activities, - required this.loading, - required this.hasCompletedActivity, - }); - @override - State createState() => TopicActivitiesListState(); -} - -class TopicActivitiesListState extends State { - final ScrollController _scrollController = ScrollController(); - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isColumnMode = FluffyThemes.isColumnMode(context); - - final activityEntries = widget.activities.entries.toList(); - activityEntries.sort( - (a, b) => a.value.req.numberOfParticipants.compareTo( - b.value.req.numberOfParticipants, - ), - ); - - return Scrollbar( - thumbVisibility: true, - controller: _scrollController, - child: ListView.builder( - controller: _scrollController, - scrollDirection: Axis.horizontal, - itemCount: activityEntries.length, - itemBuilder: (context, index) { - final activityEntry = activityEntries[index]; - final complete = widget.hasCompletedActivity( - widget.room.client.userID!, - activityEntry.key, - ); - - final activity = activityEntry.value; - return Padding( - padding: index != (activityEntries.length - 1) - ? const EdgeInsets.only(right: 24.0) - : EdgeInsets.zero, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => context.go( - "/rooms/spaces/${widget.room.id}/activity/${activityEntry.key}", - ), - child: Stack( - children: [ - ActivitySuggestionCard( - activity: activity, - width: isColumnMode ? 160.0 : 120.0, - height: isColumnMode ? 280.0 : 200.0, - fontSize: isColumnMode ? 20.0 : 12.0, - fontSizeSmall: isColumnMode ? 12.0 : 8.0, - iconSize: isColumnMode ? 12.0 : 8.0, - ), - if (widget.loading) - Container( - width: isColumnMode ? 160.0 : 120.0, - height: isColumnMode ? 280.0 : 200.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - color: theme.colorScheme.surface.withAlpha(180), - ), - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ) - else if (complete) - Container( - width: isColumnMode ? 160.0 : 120.0, - height: isColumnMode ? 280.0 : 200.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - color: theme.colorScheme.surface.withAlpha(180), - ), - child: Center( - child: SvgPicture.asset( - "assets/pangea/check.svg", - width: 48.0, - height: 48.0, - ), - ), - ), - ], - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/pangea/course_settings/topic_activities_list.dart b/lib/pangea/course_settings/topic_activities_list.dart new file mode 100644 index 000000000..50fe4a480 --- /dev/null +++ b/lib/pangea/course_settings/topic_activities_list.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_svg/svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart'; +import 'package:fluffychat/pangea/course_settings/activity_card_placeholder.dart'; + +class TopicActivitiesList extends StatefulWidget { + final Room room; + final CourseTopicModel topic; + final bool Function(String userId, String activityId) hasCompletedActivity; + + const TopicActivitiesList({ + super.key, + required this.room, + required this.topic, + required this.hasCompletedActivity, + }); + @override + State createState() => TopicActivitiesListState(); +} + +class TopicActivitiesListState extends State { + final ScrollController _scrollController = ScrollController(); + + bool _loading = false; + Object? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant TopicActivitiesList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.topic.uuid != widget.topic.uuid) { + _load(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future _load() async { + if (widget.topic.activityListComplete) return; + + setState(() { + _loading = true; + _error = null; + }); + + try { + await widget.topic.fetchActivities(); + } catch (e, s) { + _error = e; + ErrorHandler.logError(e: e, s: s, data: {'topicId': widget.topic.uuid}); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return ActivityCardPlaceholder( + activityCount: widget.topic.activityIds.length, + ); + } + + if (_error != null) { + return ErrorIndicator(message: L10n.of(context).oopsSomethingWentWrong); + } + + final theme = Theme.of(context); + final isColumnMode = FluffyThemes.isColumnMode(context); + + final activityEntries = widget.topic.loadedActivities.entries.toList(); + activityEntries.sort( + (a, b) => a.value.req.numberOfParticipants.compareTo( + b.value.req.numberOfParticipants, + ), + ); + + if (activityEntries.isEmpty) { + return SizedBox(); + } + + return Scrollbar( + thumbVisibility: true, + controller: _scrollController, + child: ListView.builder( + controller: _scrollController, + scrollDirection: Axis.horizontal, + itemCount: activityEntries.length, + itemBuilder: (context, index) { + final activityEntry = activityEntries[index]; + final complete = widget.hasCompletedActivity( + widget.room.client.userID!, + activityEntry.key, + ); + + final activity = activityEntry.value; + return Padding( + padding: index != (activityEntries.length - 1) + ? const EdgeInsets.only(right: 24.0) + : EdgeInsets.zero, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => context.go( + "/rooms/spaces/${widget.room.id}/activity/${activityEntry.key}", + ), + child: Stack( + children: [ + ActivitySuggestionCard( + activity: activity, + width: isColumnMode ? 160.0 : 120.0, + height: isColumnMode ? 280.0 : 200.0, + fontSize: isColumnMode ? 20.0 : 12.0, + fontSizeSmall: isColumnMode ? 12.0 : 8.0, + iconSize: isColumnMode ? 12.0 : 8.0, + ), + if (complete) + Container( + width: isColumnMode ? 160.0 : 120.0, + height: isColumnMode ? 280.0 : 200.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: theme.colorScheme.surface.withAlpha(180), + ), + child: Center( + child: SvgPicture.asset( + "assets/pangea/check.svg", + width: 48.0, + height: 48.0, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +}