From 7f26218026237af11732487276144da9f45bf459 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 2 Dec 2025 10:15:05 -0500 Subject: [PATCH 1/2] feat: allow admins to view and join ongoing/completed activities --- lib/l10n/intl_en.arb | 29 +++++- .../activity_session_start_page.dart | 31 +++++++ .../activity_sessions_start_view.dart | 89 ++++++++++++++++++- .../utils/get_chat_list_item_subtitle.dart | 6 +- .../activity_template_chat_list_item.dart | 4 +- .../course_chats/course_chats_page.dart | 9 +- .../extended_space_rooms_chunk.dart | 6 +- .../course_chats/open_roles_indicator.dart | 80 ++++++++++------- .../activity_summaries_provider.dart | 56 ++++++++++++ 9 files changed, 262 insertions(+), 48 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e35bc25aa..bac03b3e2 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5307,5 +5307,32 @@ "vocabLevelsDesc": "This is where vocab words will go once you’ve leveled them up!", "highlightVocabTooltip": "Highlight target vocab words below by sending them or practicing with them in the chat", "teacherModeTitle": "Teacher Mode", - "teacherModeDesc": "Toggle to unlock all topics and activities. Course admin only." + "teacherModeDesc": "Toggle to unlock all topics and activities. Course admin only.", + "notStartedActivitiesTitle": "Open sessions ({num})", + "@notStartedActivitiesTitle": { + "type": "String", + "placeholders": { + "num": { + "type": "int" + } + } + }, + "inProgressActivitiesTitle": "Happening now ({num})", + "@inProgressActivitiesTitle": { + "type": "String", + "placeholders": { + "num": { + "type": "int" + } + } + }, + "completedActivitiesTitle": "Done ({num})", + "@completedActivitiesTitle": { + "type": "String", + "placeholders": { + "num": { + "type": "int" + } + } + } } diff --git a/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart b/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart index 7d9836d37..30c00937c 100644 --- a/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart +++ b/lib/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart @@ -12,6 +12,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_start/bot_join_error_dialog.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_translation_request.dart'; @@ -187,6 +188,9 @@ class ActivitySessionStartController extends State return false; } + Map> + get activityStatuses => activitySessionStatuses(widget.activityId); + void toggleInstructions() { setState(() { showInstructions = !showInstructions; @@ -396,6 +400,33 @@ class ActivitySessionStartController extends State return joinedSessionId; } + Future joinActivityByRoomId(String roomId) async { + final resp = await showFutureLoadingDialog( + context: context, + future: () async { + await courseParent!.client.joinRoom( + roomId, + via: courseParent?.spaceChildren + .firstWhereOrNull( + (child) => child.roomId == roomId, + ) + ?.via, + ); + + final room = courseParent!.client.getRoomById(roomId); + if (room == null || room.membership != Membership.join) { + await courseParent!.client.waitForRoomInSync(roomId, join: true); + } + }, + ); + + if (!resp.isError) { + widget.parentId != null + ? context.go("/rooms/spaces/${widget.parentId}/$roomId") + : context.go("/rooms/$roomId"); + } + } + Future pingCourse() async { if (activityRoom?.courseParent == null) { throw Exception("Activity is not part of a course"); diff --git a/lib/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart b/lib/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart index 911aa2510..8709f6a06 100644 --- a/lib/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart +++ b/lib/pangea/activity_sessions/activity_session_start/activity_sessions_start_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.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'; @@ -10,12 +11,16 @@ import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activ import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_feedback_response_dialog.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_session_start_page.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_summary_widget.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; import 'package:fluffychat/pangea/common/widgets/error_indicator.dart'; +import 'package:fluffychat/pangea/course_chats/open_roles_indicator.dart'; +import 'package:fluffychat/pangea/course_plans/course_activities/activity_summaries_provider.dart'; import 'package:fluffychat/pangea/course_plans/course_activities/course_activity_repo.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; class ActivitySessionStartView extends StatelessWidget { final ActivitySessionStartController controller; @@ -129,7 +134,6 @@ class ActivitySessionStartView extends StatelessWidget { ), padding: const EdgeInsets.all(12.0), child: Column( - spacing: 12.0, children: [ ActivitySummary( activity: controller.activity!, @@ -151,6 +155,12 @@ class ActivitySessionStartView extends StatelessWidget { .roles ?? {}, ), + if (controller.courseParent != null) + _ActivityStatuses( + statuses: controller.activityStatuses, + space: controller.courseParent!, + onTap: controller.joinActivityByRoomId, + ), ], ), ), @@ -393,3 +403,80 @@ class _ActivityStartButtons extends StatelessWidget { ); } } + +class _ActivityStatuses extends StatelessWidget { + final Map> statuses; + final Room space; + final Function(String) onTap; + + const _ActivityStatuses({ + required this.statuses, + required this.space, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 1.5, + ), + child: Column( + children: [ + ...ActivitySummaryStatus.values.map( + (status) { + final entry = statuses[status]; + if (entry!.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsetsGeometry.symmetric( + horizontal: 20.0, + vertical: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + status.label(L10n.of(context), entry.length), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ...entry.entries.map((e) { + final summary = e.value; + final roomId = e.key; + return ListTile( + title: OpenRolesIndicator( + roles: summary.activityPlan.roles.values.toList(), + assignedRoles: + summary.activityRoles.roles.values.toList(), + size: 40.0, + spacing: 8.0, + space: space, + onUserTap: (user, context) { + showMemberActionsPopupMenu( + context: context, + user: user, + ); + }, + ), + trailing: const Icon(Icons.arrow_forward), + onTap: space.isRoomAdmin ? () => onTap(roomId) : null, + ); + }), + ], + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart b/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart index 00a18a74a..0441a3a7c 100644 --- a/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart +++ b/lib/pangea/chat_list/utils/get_chat_list_item_subtitle.dart @@ -66,10 +66,8 @@ class ChatListItemSubtitle extends StatelessWidget { ); } else if (!room.isActivityStarted) { return OpenRolesIndicator( - totalSlots: room.activityPlan!.req.numberOfParticipants, - userIds: - room.activityRoles?.roles.values.map((r) => r.userId).toList() ?? - [], + roles: room.activityPlan!.roles.values.toList(), + assignedRoles: room.assignedRoles?.values.toList() ?? [], room: room, space: room.courseParent, ); diff --git a/lib/pangea/course_chats/activity_template_chat_list_item.dart b/lib/pangea/course_chats/activity_template_chat_list_item.dart index dc4dc52e9..37fd7fe61 100644 --- a/lib/pangea/course_chats/activity_template_chat_list_item.dart +++ b/lib/pangea/course_chats/activity_template_chat_list_item.dart @@ -97,8 +97,8 @@ class ActivityTemplateChatListItem extends StatelessWidget { children: [ Expanded( child: OpenRolesIndicator( - totalSlots: activity.req.numberOfParticipants, - userIds: e.userIds, + roles: activity.roles.values.toList(), + assignedRoles: e.assignedRoles, space: space, ), ), diff --git a/lib/pangea/course_chats/course_chats_page.dart b/lib/pangea/course_chats/course_chats_page.dart index 3baab1d07..4f82430b5 100644 --- a/lib/pangea/course_chats/course_chats_page.dart +++ b/lib/pangea/course_chats/course_chats_page.dart @@ -131,8 +131,7 @@ class CourseChatsController extends State } final activity = summary.activityPlan; - final users = - summary.activityRoles.roles.values.map((r) => r.userId).toList(); + final users = summary.activityRoles.roles.values.toList(); if (users.isEmpty || !validIDs.contains(activity.activityId)) { continue; @@ -155,7 +154,7 @@ class CourseChatsController extends State sessionsMap[activity]!.add( ExtendedSpaceRoomsChunk( chunk: chunk, - userIds: users, + assignedRoles: users, ), ); } @@ -454,7 +453,9 @@ class CourseChatsController extends State String activityId, ExtendedSpaceRoomsChunk chunk, ) async { - final hasRole = chunk.userIds.contains(widget.client.userID); + final hasRole = chunk.assignedRoles.any( + (role) => role.userId == widget.client.userID, + ); final roomId = chunk.chunk.roomId; if (!hasRole) { context.go( diff --git a/lib/pangea/course_chats/extended_space_rooms_chunk.dart b/lib/pangea/course_chats/extended_space_rooms_chunk.dart index f2621a0e7..45a6748e4 100644 --- a/lib/pangea/course_chats/extended_space_rooms_chunk.dart +++ b/lib/pangea/course_chats/extended_space_rooms_chunk.dart @@ -1,11 +1,13 @@ import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; + class ExtendedSpaceRoomsChunk { final SpaceRoomsChunk chunk; - final List userIds; + final List assignedRoles; ExtendedSpaceRoomsChunk({ required this.chunk, - required this.userIds, + required this.assignedRoles, }); } diff --git a/lib/pangea/course_chats/open_roles_indicator.dart b/lib/pangea/course_chats/open_roles_indicator.dart index 4ba2eed46..e90e7095b 100644 --- a/lib/pangea/course_chats/open_roles_indicator.dart +++ b/lib/pangea/course_chats/open_roles_indicator.dart @@ -1,63 +1,75 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; import 'package:fluffychat/widgets/avatar.dart'; class OpenRolesIndicator extends StatelessWidget { - final int totalSlots; - final List userIds; + final List roles; + final List assignedRoles; final Room? room; final Room? space; + final double? spacing; + final double? size; + final Function(User, BuildContext)? onUserTap; + const OpenRolesIndicator({ super.key, - required this.totalSlots, - required this.userIds, + required this.roles, + required this.assignedRoles, this.room, this.space, + this.spacing, + this.size, + this.onUserTap, }); @override Widget build(BuildContext context) { - final remainingSlots = max( - 0, - totalSlots - userIds.length, - ); - final roomParticipants = room?.getParticipants() ?? []; final spaceParticipants = space?.getParticipants() ?? []; return Row( - spacing: 2.0, + spacing: spacing ?? 2.0, children: [ - ...userIds.map((u) { - final user = roomParticipants.firstWhereOrNull((p) => p.id == u) ?? - spaceParticipants.firstWhereOrNull((p) => p.id == u); + ...roles.map((role) { + final assigned = + assignedRoles.firstWhereOrNull((r) => r.id == role.id); - return Avatar( - mxContent: user?.avatarUrl, - name: user?.calcDisplayname() ?? u.localpart ?? u, - size: 16, - userId: u, - ); - }), - ...List.generate(remainingSlots, (_) { - return Stack( - alignment: Alignment.center, - children: [ - CircleAvatar( - radius: 8, - backgroundColor: Theme.of(context).colorScheme.primary, + final user = assigned != null + ? roomParticipants + .firstWhereOrNull((p) => p.id == assigned.userId) ?? + spaceParticipants + .firstWhereOrNull((p) => p.id == assigned.userId) + : null; + + if (assigned != null) { + return Builder( + builder: (context) => Avatar( + mxContent: user?.avatarUrl, + name: user?.calcDisplayname() ?? + assigned.userId.localpart ?? + assigned.userId, + size: size ?? 16, + userId: assigned.userId, + onTap: onUserTap != null && user != null + ? () => onUserTap!(user, context) + : null, ), - CircleAvatar( - radius: 7, - backgroundColor: Theme.of(context).colorScheme.surface, - ), - ], + ); + } + + return CircleAvatar( + radius: size != null ? size! / 2 : 8, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Icon( + Icons.question_mark, + size: size != null ? (size! / 2) : 8, + ), ); }), ], 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 0fdef8f17..e534f8df0 100644 --- a/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart +++ b/lib/pangea/course_plans/course_activities/activity_summaries_provider.dart @@ -2,12 +2,30 @@ import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/chat_settings/utils/room_summary_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_topics/course_topic_model.dart'; import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; +enum ActivitySummaryStatus { + notStarted, + inProgress, + completed; + + String label(L10n l10n, int count) { + switch (this) { + case ActivitySummaryStatus.notStarted: + return l10n.notStartedActivitiesTitle(count); + case ActivitySummaryStatus.inProgress: + return l10n.inProgressActivitiesTitle(count); + case ActivitySummaryStatus.completed: + return l10n.completedActivitiesTitle(count); + } + } +} + mixin ActivitySummariesProvider on State { Map? roomSummaries; @@ -23,6 +41,44 @@ mixin ActivitySummariesProvider on State { } } + Map activitySessions(String activityId) => + Map.fromEntries( + roomSummaries?.entries + .where((v) => v.value.activityPlan.activityId == activityId) ?? + [], + ); + + Map> + activitySessionStatuses( + String activityId, + ) { + final statuses = >{ + ActivitySummaryStatus.notStarted: {}, + ActivitySummaryStatus.inProgress: {}, + ActivitySummaryStatus.completed: {}, + }; + + final sessions = activitySessions(activityId); + for (final entry in sessions.entries) { + final session = entry.value; + final roomId = entry.key; + final roles = session.activityRoles.roles.values; + + if (roles.isNotEmpty && + (roles.any((r) => r.isArchived) || + roles.every((r) => r.isFinished))) { + statuses[ActivitySummaryStatus.completed]![roomId] = session; + } else if (session.activityRoles.roles.length < + session.activityPlan.req.numberOfParticipants) { + statuses[ActivitySummaryStatus.notStarted]![roomId] = session; + } else { + statuses[ActivitySummaryStatus.inProgress]![roomId] = session; + } + } + + return statuses; + } + Set openSessions(String activityId) { if (roomSummaries == null || roomSummaries!.isEmpty) return {}; final Set sessions = {}; From dd1b0604d5bfac65bfe58506b8ca3e3162756025 Mon Sep 17 00:00:00 2001 From: ggurdin Date: Tue, 2 Dec 2025 10:16:43 -0500 Subject: [PATCH 2/2] build: bump version number --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index f22f587ea..c05c2fa93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ description: Learn a language while texting your friends. # Pangea# publish_to: none # On version bump also increase the build number for F-Droid -version: 4.1.15+7 +version: 4.1.15+8 environment: sdk: ">=3.0.0 <4.0.0"