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_participant_list.dart b/lib/pangea/activity_sessions/activity_participant_list.dart index b8fab53e4..9cfb004ca 100644 --- a/lib/pangea/activity_sessions/activity_participant_list.dart +++ b/lib/pangea/activity_sessions/activity_participant_list.dart @@ -40,7 +40,8 @@ class ActivityParticipantList extends StatelessWidget { room: room, builder: (context, participants) { final theme = Theme.of(context); - final availableRoles = activity.roles; + final availableRoles = + activity.roles.values.sorted((a, b) => a.id.compareTo(b.id)); final remainingMembers = participants.participants.where( (p) => !assignedRoles.values.any((r) => r.userId == p.id), @@ -53,7 +54,7 @@ class ActivityParticipantList extends StatelessWidget { alignment: WrapAlignment.center, spacing: 12.0, runSpacing: 12.0, - children: availableRoles.values.map((availableRole) { + children: availableRoles.map((availableRole) { final selected = isSelected != null ? isSelected!(availableRole.id) : false; 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..72175e6d5 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'; @@ -153,7 +154,7 @@ class ActivitySessionStartController extends State final availableRoles = activity!.roles; final assignedRoles = activityRoom?.assignedRoles ?? - roomSummaries?[widget.roomId]?.activityRoles.roles ?? + roomSummaries?[widget.roomId]?.joinedUsersWithRoles ?? {}; final unassignedIds = availableRoles.keys .where((id) => !assignedRoles.containsKey(id)) @@ -187,6 +188,9 @@ class ActivitySessionStartController extends State return false; } + Map> + get activityStatuses => activitySessionStatuses(widget.activityId); + void toggleInstructions() { setState(() { showInstructions = !showInstructions; @@ -396,6 +400,41 @@ class ActivitySessionStartController extends State return joinedSessionId; } + Future joinActivityByRoomId(String roomId) async { + final room = Matrix.of(context).client.getRoomById(roomId); + if (room != null && room.membership == Membership.join) { + widget.parentId != null + ? context.go("/rooms/spaces/${widget.parentId}/$roomId") + : context.go("/rooms/$roomId"); + return; + } + + 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..ba330d719 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,21 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.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_feedback/activity_feedback_repo.dart'; import 'package:fluffychat/pangea/activity_feedback/activity_feedback_request.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_start/activity_feedback_request_dialog.dart'; 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 +137,6 @@ class ActivitySessionStartView extends StatelessWidget { ), padding: const EdgeInsets.all(12.0), child: Column( - spacing: 12.0, children: [ ActivitySummary( activity: controller.activity!, @@ -147,10 +154,15 @@ class ActivitySessionStartView extends StatelessWidget { assignedRoles: controller .roomSummaries?[ controller.widget.roomId] - ?.activityRoles - .roles ?? + ?.joinedUsersWithRoles ?? {}, ), + if (controller.courseParent != null) + _ActivityStatuses( + statuses: controller.activityStatuses, + space: controller.courseParent!, + onTap: controller.joinActivityByRoomId, + ), ], ), ), @@ -393,3 +405,104 @@ 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) { + // if user is in the room, use the room info instead of the + // room summary response to get real-time activity roles info + final roomId = e.key; + final room = + Matrix.of(context).client.getRoomById(roomId); + + final activityPlan = + room?.activityPlan ?? e.value.activityPlan; + + // If activity is completed, show all roles, even for users who have left the + // room (like the bot). Otherwise, show only joined users with roles + Map activityRoles = + status == ActivitySummaryStatus.completed + ? e.value.activityRoles.roles + : e.value.joinedUsersWithRoles; + + // If the user is in the activity room and it's not completed, use the room's + // state events to determine roles to update them in real-time + if (room?.assignedRoles != null && + status != ActivitySummaryStatus.completed) { + activityRoles = room!.assignedRoles!; + } + + return ListTile( + title: OpenRolesIndicator( + roles: activityPlan.roles.values + .sorted((a, b) => a.id.compareTo(b.id)) + .toList(), + assignedRoles: activityRoles.values.toList(), + size: 40.0, + spacing: 8.0, + space: space, + onUserTap: (user, context) { + showMemberActionsPopupMenu( + context: context, + user: user, + ); + }, + ), + trailing: space.isRoomAdmin + ? const Icon(Icons.arrow_forward) + : null, + 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/chat_settings/utils/room_summary_extension.dart b/lib/pangea/chat_settings/utils/room_summary_extension.dart index ac1eb415c..fd72f3669 100644 --- a/lib/pangea/chat_settings/utils/room_summary_extension.dart +++ b/lib/pangea/chat_settings/utils/room_summary_extension.dart @@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart'; import 'package:matrix/matrix_api_lite/generated/api.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.dart'; import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; @@ -64,12 +65,31 @@ class RoomSummariesResponse { class RoomSummaryResponse { final ActivityPlanModel activityPlan; final ActivityRolesModel activityRoles; + final Map membershipSummary; RoomSummaryResponse({ required this.activityPlan, required this.activityRoles, + required this.membershipSummary, }); + Membership? getMembershipForUserId(String userId) { + final membershipString = membershipSummary[userId]; + if (membershipString == null) return null; + return Membership.values.firstWhere( + (m) => m.name == membershipString, + orElse: () => Membership.join, + ); + } + + Map get joinedUsersWithRoles { + return Map.fromEntries( + activityRoles.roles.entries.where( + (role) => getMembershipForUserId(role.value.userId) == Membership.join, + ), + ); + } + factory RoomSummaryResponse.fromJson(Map json) { return RoomSummaryResponse( activityPlan: ActivityPlanModel.fromJson( @@ -78,6 +98,9 @@ class RoomSummaryResponse { activityRoles: ActivityRolesModel.fromJson( json[PangeaEventTypes.activityRole]?["default"]?["content"] ?? {}, ), + membershipSummary: Map.from( + json['membership_summary'] ?? {}, + ), ); } @@ -85,6 +108,7 @@ class RoomSummaryResponse { return { PangeaEventTypes.activityPlan: activityPlan.toJson(), PangeaEventTypes.activityRole: activityRoles.toJson(), + 'membership_summary': membershipSummary, }; } } diff --git a/lib/pangea/chat_settings/widgets/chat_context_menu_action.dart b/lib/pangea/chat_settings/widgets/chat_context_menu_action.dart index 618022be4..2af53efcb 100644 --- a/lib/pangea/chat_settings/widgets/chat_context_menu_action.dart +++ b/lib/pangea/chat_settings/widgets/chat_context_menu_action.dart @@ -252,6 +252,12 @@ void chatContextMenuAction( context: context, future: room.isSpace ? room.leaveSpace : room.leave, ); + + final r = room.client.getRoomById(room.id); + if (r != null && r.membership != Membership.leave) { + await room.client.waitForRoomInSync(room.id, leave: true); + } + if (!resp.isError) { outerContext.go( room.courseParent != null 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..13a644763 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.joinedUsersWithRoles; 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.values.toList(), ), ); } @@ -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 91e4f5544..3963caeac 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,72 @@ mixin ActivitySummariesProvider on State { } } + bool isActivityStarted(String roomId) { + if (isActivityFinished(roomId)) return true; + final roomSummary = roomSummaries?[roomId]; + if (roomSummary == null) return false; + + final activityPlan = roomSummary.activityPlan; + final assignedRoles = roomSummary.joinedUsersWithRoles; + return activityPlan.roles.length - assignedRoles.length <= 0; + } + + bool isActivityFinished(String roomId) { + final roomSummary = roomSummaries?[roomId]; + if (roomSummary == null) return false; + + final activityRoles = roomSummary.activityRoles; + final roles = activityRoles.roles.values.where( + (r) => r.userId != BotName.byEnvironment, + ); + + if (roles.isEmpty) return false; + if (!roles.any((r) => r.isFinished)) return false; + + return roles.every((r) { + if (r.isFinished) return true; + + // if the user is in the chat (not null && membership is join), + // then the activity is not finished for them + final membership = roomSummary.getMembershipForUserId(r.userId); + return membership == null || membership != Membership.join; + }); + } + + 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; + + if (isActivityFinished(roomId)) { + statuses[ActivitySummaryStatus.completed]![roomId] = session; + } else if (isActivityStarted(roomId)) { + statuses[ActivitySummaryStatus.inProgress]![roomId] = session; + } else { + statuses[ActivitySummaryStatus.notStarted]![roomId] = session; + } + } + + return statuses; + } + Set openSessions(String activityId) { if (roomSummaries == null || roomSummaries!.isEmpty) return {}; final Set sessions = {}; @@ -35,12 +119,7 @@ mixin ActivitySummariesProvider on State { continue; } - final isOpen = - !summary.activityRoles.roles.values.any((r) => r.isArchived) && - (summary.activityRoles.roles.length < - summary.activityPlan.req.numberOfParticipants); - - if (isOpen) { + if (!isActivityStarted(roomId)) { sessions.add(roomId); } } diff --git a/pubspec.yaml b/pubspec.yaml index 2d61d5620..2dd3cc8b5 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+9 +version: 4.1.15+10 environment: sdk: ">=3.0.0 <4.0.0"