3915 course chat view updates (#3919)

This commit is contained in:
ggurdin 2025-09-10 11:10:13 -04:00 committed by GitHub
parent 08eb8fe19f
commit c04466fdca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 805 additions and 283 deletions

View file

@ -5225,5 +5225,9 @@
"startNewSession": "Start new session", "startNewSession": "Start new session",
"joinOpenSession": "Join open session", "joinOpenSession": "Join open session",
"less": "less", "less": "less",
"activityNotFound": "Activity not found" "activityNotFound": "Activity not found",
"myActivities": "My activities",
"openToJoin": "Open to join",
"results": "Results",
"activityDone": "Activity Done!"
} }

View file

@ -2247,13 +2247,11 @@ class ChatController extends State<ChatPageWithRoom>
); );
} }
if (room.isActivitySession == true && if (room.isActivitySession == true && !room.activityHasStarted) {
!room.activityHasStarted &&
room.courseParent != null) {
return ActivitySessionStartPage( return ActivitySessionStartPage(
activityId: room.activityId!, activityId: room.activityId!,
room: room, room: room,
parentId: room.courseParent!.id, parentId: room.courseParent?.id,
); );
} }
// Pangea# // Pangea#

View file

@ -25,6 +25,10 @@ class ChatListItem extends StatelessWidget {
final void Function()? onForget; final void Function()? onForget;
final void Function() onTap; final void Function() onTap;
final String? filter; final String? filter;
// #Pangea
final BorderRadius? borderRadius;
final Widget? trailing;
// Pangea#
const ChatListItem( const ChatListItem(
this.room, { this.room, {
@ -35,6 +39,10 @@ class ChatListItem extends StatelessWidget {
this.filter, this.filter,
this.space, this.space,
super.key, super.key,
// #Pangea
this.borderRadius,
this.trailing,
// Pangea#
}); });
Future<bool> archiveAction(BuildContext context) async { Future<bool> archiveAction(BuildContext context) async {
@ -174,11 +182,19 @@ class ChatListItem extends StatelessWidget {
color: backgroundColor ?? color: backgroundColor ??
theme.colorScheme.surface, theme.colorScheme.surface,
), ),
borderRadius: room.isSpace // #Pangea
? BorderRadius.circular( // borderRadius: room.isSpace
AppConfig.borderRadius / 4, // ? BorderRadius.circular(
) // AppConfig.borderRadius / 4,
: null, // )
// : null,
borderRadius: borderRadius ??
(room.isSpace
? BorderRadius.circular(
AppConfig.borderRadius / 4,
)
: null),
// Pangea#
mxContent: room.avatar, mxContent: room.avatar,
size: space != null size: space != null
? Avatar.defaultSize * 0.75 ? Avatar.defaultSize * 0.75
@ -428,12 +444,21 @@ class ChatListItem extends StatelessWidget {
], ],
), ),
onTap: onTap, onTap: onTap,
trailing: onForget == null // #Pangea
? null // trailing: onForget == null
: IconButton( // ? null
icon: const Icon(Icons.delete_outlined), // : IconButton(
onPressed: onForget, // icon: const Icon(Icons.delete_outlined),
), // onPressed: onForget,
// ),
trailing: trailing ??
(onForget == null
? null
: IconButton(
icon: const Icon(Icons.delete_outlined),
onPressed: onForget,
)),
// Pangea#
), ),
), ),
), ),

View file

@ -16,6 +16,7 @@ class NaviRailItem extends StatelessWidget {
final bool Function(Room)? unreadBadgeFilter; final bool Function(Room)? unreadBadgeFilter;
// #Pangea // #Pangea
final Color? backgroundColor; final Color? backgroundColor;
final BorderRadius? borderRadius;
// Pangea# // Pangea#
const NaviRailItem({ const NaviRailItem({
@ -27,6 +28,7 @@ class NaviRailItem extends StatelessWidget {
this.unreadBadgeFilter, this.unreadBadgeFilter,
// #Pangea // #Pangea
this.backgroundColor, this.backgroundColor,
this.borderRadius,
// Pangea# // Pangea#
super.key, super.key,
}); });
@ -36,7 +38,7 @@ class NaviRailItem extends StatelessWidget {
// #Pangea // #Pangea
// final borderRadius = BorderRadius.circular(AppConfig.borderRadius); // final borderRadius = BorderRadius.circular(AppConfig.borderRadius);
final borderRadius = BorderRadius.circular(10.0); final borderRadius = this.borderRadius ?? BorderRadius.circular(10.0);
final isColumnMode = FluffyThemes.isColumnMode(context); final isColumnMode = FluffyThemes.isColumnMode(context);
final width = isColumnMode final width = isColumnMode

View file

@ -9,7 +9,6 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart
import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/saved_activity_analytics_dialog.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/saved_activity_analytics_dialog.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
@ -52,17 +51,11 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
final courseParent = controller.room.courseParent; final courseParent = controller.room.courseParent;
if (courseParent?.coursePlan == null) return; if (courseParent?.coursePlan == null) return;
final coursePlan = await CoursePlansRepo.get(
courseParent!.coursePlan!.uuid,
);
final activityId = controller.room.activityPlan!.activityId; final activityId = controller.room.activityPlan!.activityId;
final topicId = coursePlan.topicID(activityId); await courseParent!.finishCourseActivity(
if (topicId == null) { activityId,
throw L10n.of(context).activityNotFoundForCourse; controller.room.id,
} );
await courseParent.finishCourseActivity(activityId, topicId);
} }
ActivitySummaryModel? get summary => controller.room.activitySummary; ActivitySummaryModel? get summary => controller.room.activitySummary;

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
@ -10,8 +11,11 @@ import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; 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/activity_sessions_start_view.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart'; import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart';
@ -28,7 +32,7 @@ class ActivitySessionStartPage extends StatefulWidget {
final String activityId; final String activityId;
final bool isNew; final bool isNew;
final Room? room; final Room? room;
final String parentId; final String? parentId;
const ActivitySessionStartPage({ const ActivitySessionStartPage({
super.key, super.key,
required this.activityId, required this.activityId,
@ -44,6 +48,8 @@ class ActivitySessionStartPage extends StatefulWidget {
class ActivitySessionStartController extends State<ActivitySessionStartPage> { class ActivitySessionStartController extends State<ActivitySessionStartPage> {
ActivityPlanModel? activity; ActivityPlanModel? activity;
CoursePlanModel? course;
bool loading = true; bool loading = true;
Object? error; Object? error;
@ -73,9 +79,11 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage> {
Room? get room => widget.room; Room? get room => widget.room;
Room? get parent => Matrix.of(context).client.getRoomById( Room? get parent => widget.parentId != null
widget.parentId, ? Matrix.of(context).client.getRoomById(
); widget.parentId!,
)
: null;
bool get isBotRoomMember => bool get isBotRoomMember =>
room?.getParticipants().any( room?.getParticipants().any(
@ -136,6 +144,13 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage> {
return _selectedRoleId == id; return _selectedRoleId == id;
} }
bool get canJoinExistingSession {
// if the activity session already exists, if there's no parent course, or if the parent course doesn't
// have the event where existing sessions are stored, joining an existing session is not possible
if (room != null || parent?.allCourseUserStates == null) return false;
return parent!.numOpenSessions(widget.activityId) > 0;
}
void toggleInstructions() { void toggleInstructions() {
setState(() { setState(() {
showInstructions = !showInstructions; showInstructions = !showInstructions;
@ -155,6 +170,10 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage> {
error = null; error = null;
}); });
if (parent?.coursePlan != null) {
course = await CoursePlansRepo.get(parent!.coursePlan!.uuid);
}
final activities = await CourseActivityRepo.get( final activities = await CourseActivityRepo.get(
widget.activityId, widget.activityId,
[widget.activityId], [widget.activityId],
@ -172,14 +191,32 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage> {
} }
} }
Future<void> onTap() async { Future<void> confirmRoleSelection() async {
if (state != SessionState.selectedRole) return; if (state != SessionState.selectedRole) return;
if (room != null) { if (room != null) {
await showFutureLoadingDialog( await showFutureLoadingDialog(
context: context, context: context,
future: () => room!.joinActivity( future: () async {
activity!.roles[_selectedRoleId!]!, await room!.joinActivity(
), activity!.roles[_selectedRoleId!]!,
);
try {
await parent!.joinCourseActivity(
widget.activityId,
room!.id,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"activityId": widget.activityId,
"parentId": widget.parentId,
},
);
}
},
); );
} else { } else {
final resp = await showFutureLoadingDialog( final resp = await showFutureLoadingDialog(
@ -196,6 +233,43 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage> {
} }
} }
Future<String> joinExistingSession() async {
if (!canJoinExistingSession) {
throw Exception("No existing session to join");
}
final sessionIds = parent!.openSessions(widget.activityId);
String? joinedSessionId;
for (final sessionId in sessionIds) {
try {
await parent!.client.joinRoom(
sessionId,
via: parent?.spaceChildren
.firstWhereOrNull(
(child) => child.roomId == sessionId,
)
?.via,
);
joinedSessionId = sessionId;
break;
} catch (_) {
// try next session
continue;
}
}
if (joinedSessionId == null) {
throw Exception("Failed to join any existing session");
}
final room = parent!.client.getRoomById(joinedSessionId);
if (room == null || room.membership != Membership.join) {
await parent!.client.waitForRoomInSync(joinedSessionId, join: true);
}
return joinedSessionId;
}
Future<void> pingCourse() async { Future<void> pingCourse() async {
if (room?.courseParent == null) { if (room?.courseParent == null) {
throw Exception("Activity is not part of a course"); throw Exception("Activity is not part of a course");

View file

@ -150,7 +150,23 @@ class ActivitySessionStartView extends StatelessWidget {
), ),
ElevatedButton( ElevatedButton(
style: buttonStyle, style: buttonStyle,
onPressed: null, onPressed:
controller.canJoinExistingSession
? () async {
final resp =
await showFutureLoadingDialog(
context: context,
future: controller
.joinExistingSession,
);
if (!resp.isError) {
context.go(
"/rooms/spaces/${controller.widget.parentId}/${resp.result}",
);
}
}
: null,
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.center, MainAxisAlignment.center,
@ -221,7 +237,7 @@ class ActivitySessionStartView extends StatelessWidget {
ElevatedButton( ElevatedButton(
style: buttonStyle, style: buttonStyle,
onPressed: controller.enableButtons onPressed: controller.enableButtons
? controller.onTap ? controller.confirmRoleSelection
: null, : null,
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment:

View file

@ -14,6 +14,8 @@ class ActivitySuggestionCard extends StatelessWidget {
final double? fontSizeSmall; final double? fontSizeSmall;
final double? iconSize; final double? iconSize;
final int? openSessions;
const ActivitySuggestionCard({ const ActivitySuggestionCard({
super.key, super.key,
required this.activity, required this.activity,
@ -22,6 +24,7 @@ class ActivitySuggestionCard extends StatelessWidget {
this.fontSize, this.fontSize,
this.fontSizeSmall, this.fontSizeSmall,
this.iconSize, this.iconSize,
this.openSessions,
}); });
@override @override
@ -66,14 +69,16 @@ class ActivitySuggestionCard extends StatelessWidget {
spacing: 8.0, spacing: 8.0,
children: [ children: [
if (activity.req.mode.isNotEmpty) if (activity.req.mode.isNotEmpty)
Padding( Expanded(
padding: const EdgeInsets.all(4.0), child: Padding(
child: Text( padding: const EdgeInsets.all(4.0),
activity.req.mode, child: Text(
style: fontSizeSmall != null activity.req.mode,
? TextStyle(fontSize: fontSizeSmall) style: fontSizeSmall != null
: theme.textTheme.labelSmall, ? TextStyle(fontSize: fontSizeSmall)
overflow: TextOverflow.ellipsis, : theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
), ),
), ),
Padding( Padding(
@ -95,6 +100,26 @@ class ActivitySuggestionCard extends StatelessWidget {
], ],
), ),
), ),
if (openSessions != null && openSessions! > 0)
Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4.0,
children: [
Icon(
Icons.chat_bubble_outline,
size: iconSize ?? 12.0,
),
Text(
"$openSessions",
style: fontSizeSmall != null
? TextStyle(fontSize: fontSizeSmall)
: theme.textTheme.labelSmall,
),
],
),
),
], ],
), ),
], ],

View file

@ -5,6 +5,7 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/course_chats/open_roles_indicator.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
@ -56,12 +57,29 @@ class ChatListItemSubtitle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (room.showActivityChatUI) { if (room.showActivityChatUI) {
return Text( if (room.isHiddenActivityRoom) {
room.activityPlan!.learningObjective, return Text(
style: style, room.activityPlan!.learningObjective,
maxLines: 2, style: style,
overflow: TextOverflow.ellipsis, maxLines: 2,
); overflow: TextOverflow.ellipsis,
);
} else if (!room.activityHasStarted) {
return OpenRolesIndicator(
totalSlots: room.activityPlan!.req.numberOfParticipants,
userIds:
room.activityRoles?.roles.values.map((r) => r.userId).toList() ??
[],
space: room.courseParent,
);
} else if (room.activityIsFinished) {
return Text(
L10n.of(context).activityDone,
style: style,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
}
} }
final event = room.lastEvent; final event = room.lastEvent;

View file

@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart';
import 'package:fluffychat/pangea/course_chats/extended_space_rooms_chunk.dart';
import 'package:fluffychat/pangea/course_chats/open_roles_indicator.dart';
import 'package:fluffychat/widgets/avatar.dart';
class ActivityTemplateChatListItem extends StatelessWidget {
final Room space;
final Function(SpaceRoomsChunk) joinActivity;
final ActivityPlanModel activity;
final List<ExtendedSpaceRoomsChunk> sessions;
const ActivityTemplateChatListItem({
super.key,
required this.space,
required this.joinActivity,
required this.activity,
required this.sessions,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 1,
),
child: Column(
spacing: 10.0,
mainAxisSize: MainAxisSize.min,
children: [
Material(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
clipBehavior: Clip.hardEdge,
child: ListTile(
visualDensity: const VisualDensity(vertical: -0.5),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
leading: ImageByUrl(
imageUrl: activity.imageURL,
width: Avatar.defaultSize,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
replacement: Avatar(
name: activity.title,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
),
),
trailing: Row(
spacing: 2.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.chat_bubble_outline,
size: 8.0,
),
Text(
"${sessions.length}",
style: const TextStyle(
fontSize: 8.0,
),
),
],
),
title: Row(
children: [
Expanded(
child: Text(
activity.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
...sessions.map(
(e) {
return Padding(
padding: const EdgeInsets.only(
top: 4.0,
bottom: 4.0,
right: 4.0,
left: 14.0,
),
child: Row(
children: [
Expanded(
child: OpenRolesIndicator(
totalSlots: activity.req.numberOfParticipants,
userIds: e.userIds,
space: space,
),
),
SizedBox(
height: 24.0,
width: 40.0,
child: ElevatedButton(
onPressed: () => joinActivity(e.chunk),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(0),
),
child: Text(
L10n.of(context).join,
style: const TextStyle(
fontSize: 12,
),
),
),
),
],
),
);
},
),
],
),
);
}
}

View file

@ -9,14 +9,16 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_view.dart'; import 'package:fluffychat/pangea/course_chats/course_chats_view.dart';
import 'package:fluffychat/pangea/course_chats/extended_space_rooms_chunk.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
@ -26,6 +28,7 @@ import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'
import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CourseChats extends StatefulWidget { class CourseChats extends StatefulWidget {
final Client client; final Client client;
@ -54,7 +57,6 @@ class CourseChatsController extends State<CourseChats> {
bool isLoading = false; bool isLoading = false;
CoursePlanModel? course; CoursePlanModel? course;
String? selectedTopicId;
@override @override
void initState() { void initState() {
@ -62,7 +64,8 @@ class CourseChatsController extends State<CourseChats> {
// Listen for changes to the activeSpace's hierarchy, // Listen for changes to the activeSpace's hierarchy,
// and reload the hierarchy when they come through // and reload the hierarchy when they come through
_roomSubscription ??= widget.client.onSync.stream _roomSubscription?.cancel();
_roomSubscription = widget.client.onSync.stream
.where(_hasHierarchyUpdate) .where(_hasHierarchyUpdate)
.listen((update) => loadHierarchy(reload: true)); .listen((update) => loadHierarchy(reload: true));
super.initState(); super.initState();
@ -74,6 +77,11 @@ class CourseChatsController extends State<CourseChats> {
// via the navigation rail, so this accounts for that // via the navigation rail, so this accounts for that
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.roomId != widget.roomId) { if (oldWidget.roomId != widget.roomId) {
_roomSubscription?.cancel();
_roomSubscription = widget.client.onSync.stream
.where(_hasHierarchyUpdate)
.listen((update) => loadHierarchy(reload: true));
discoveredChildren = null; discoveredChildren = null;
_nextBatch = null; _nextBatch = null;
noMoreRooms = false; noMoreRooms = false;
@ -94,38 +102,77 @@ class CourseChatsController extends State<CourseChats> {
}); });
} }
void setSelectedTopicId(String topicID) { Set<String> get childrenIds =>
setState(() { room?.spaceChildren.map((c) => c.roomId).whereType<String>().toSet() ??
selectedTopicId = topicID; {};
});
}
int get _selectedTopicIndex => List<Room> get joinedRooms => Matrix.of(context)
course?.loadedTopics.indexWhere((t) => t.uuid == selectedTopicId) ?? -1; .client
.rooms
.where((room) => childrenIds.contains(room.id))
.where((room) => !room.isHiddenRoom)
.toList();
bool get canMoveLeft => _selectedTopicIndex > 0; List<Room> joinedActivities() =>
bool get canMoveRight { joinedRooms.where((r) => r.isActivitySession).toList();
if (course == null) return false;
final endIndex = room?.ownCurrentTopicIndex(course!) ??
(course!.loadedTopics.length - 1);
return _selectedTopicIndex < endIndex;
}
void moveLeft() { List<SpaceRoomsChunk> get discoveredGroupChats => (discoveredChildren ?? [])
if (canMoveLeft) { .where(
setSelectedTopicId(course!.loadedTopics[_selectedTopicIndex - 1].uuid); (chunk) =>
chunk.roomType == null ||
!chunk.roomType!.startsWith(PangeaRoomTypes.activitySession),
)
.toList();
Map<ActivityPlanModel, List<ExtendedSpaceRoomsChunk>> discoveredActivities() {
if (discoveredChildren == null) return {};
final courseStates = room?.allCourseUserStates ?? {};
final Map<String, List<String>> roomsToUsers = {};
if (courseStates.isNotEmpty) {
for (final state in courseStates.values) {
final userID = state.userID;
for (final roomId in state.joinedActivityRooms) {
roomsToUsers[roomId] ??= [];
roomsToUsers[roomId]!.add(userID);
}
}
} }
}
void moveRight() { final Map<ActivityPlanModel, List<ExtendedSpaceRoomsChunk>> sessionsMap =
if (canMoveRight) { {};
setSelectedTopicId(course!.loadedTopics[_selectedTopicIndex + 1].uuid);
}
}
CourseTopicModel? get selectedTopic => course?.loadedTopics.firstWhereOrNull( for (final chunk in discoveredChildren!) {
(topic) => topic.uuid == selectedTopicId, if (chunk.roomType?.startsWith(PangeaRoomTypes.activitySession) != true) {
continue;
}
final activityId = chunk.roomType!.split(":").last;
final activity = course?.activityById(activityId);
if (activity == null) {
continue;
}
final users = roomsToUsers[chunk.roomId];
if (users != null && activity.req.numberOfParticipants <= users.length) {
// Don't show full activities
continue;
}
sessionsMap[activity] ??= [];
sessionsMap[activity]!.add(
ExtendedSpaceRoomsChunk(
chunk: chunk,
activityId: activityId,
userIds: users ?? [],
),
); );
}
return sessionsMap;
}
List<Room> get joinedChats =>
joinedRooms.where((room) => !room.isActivitySession).toList();
Future<void> _joinDefaultChats() async { Future<void> _joinDefaultChats() async {
if (discoveredChildren == null) return; if (discoveredChildren == null) return;
@ -623,7 +670,7 @@ class CourseChatsController extends State<CourseChats> {
future: room.isSpace ? room.leaveSpace : room.leave, future: room.isSpace ? room.leaveSpace : room.leave,
); );
if (mounted && !resp.isError) { if (mounted && !resp.isError) {
context.go("/rooms"); context.go("/rooms/spaces/${widget.roomId}/details");
} }
return; return;
@ -711,17 +758,56 @@ class CourseChatsController extends State<CourseChats> {
/// Used to filter out sync updates with hierarchy updates for the active /// Used to filter out sync updates with hierarchy updates for the active
/// space so that the view can be auto-reloaded in the room subscription /// space so that the view can be auto-reloaded in the room subscription
bool _hasHierarchyUpdate(SyncUpdate update) { bool _hasHierarchyUpdate(SyncUpdate update) {
final joinTimeline = update.rooms?.join?[widget.roomId]?.timeline; final joinUpdate = update.rooms?.join;
final leaveTimeline = update.rooms?.leave?[widget.roomId]?.timeline; final leaveUpdate = update.rooms?.leave;
if (joinUpdate == null && leaveUpdate == null) return false;
final joinedRooms = joinUpdate?.entries
.where(
(e) => childrenIds.contains(e.key),
)
.map((e) => e.value.timeline?.events)
.whereType<List<MatrixEvent>>();
final leftRooms = leaveUpdate?.entries
.where(
(e) => childrenIds.contains(e.key),
)
.map((e) => e.value.timeline?.events)
.whereType<List<MatrixEvent>>();
final bool hasJoinedRoom = joinedRooms?.any(
(events) => events.any(
(e) =>
e.senderId == widget.client.userID &&
e.type == EventTypes.RoomMember,
),
) ??
false;
final bool hasLeftRoom = leftRooms?.any(
(events) => events.any(
(e) =>
e.senderId == widget.client.userID &&
e.type == EventTypes.RoomMember,
),
) ??
false;
if (hasJoinedRoom || hasLeftRoom) {
return true;
}
final joinTimeline = joinUpdate?[widget.roomId]?.timeline?.events;
final leaveTimeline = leaveUpdate?[widget.roomId]?.timeline?.events;
if (joinTimeline == null && leaveTimeline == null) return false; if (joinTimeline == null && leaveTimeline == null) return false;
final bool hasJoinUpdate = joinTimeline?.events?.any(
(event) => event.type == EventTypes.SpaceChild, final bool hasJoinUpdate = joinTimeline!.any(
) ?? (event) => event.type == EventTypes.SpaceChild,
false; );
final bool hasLeaveUpdate = leaveTimeline?.events?.any( final bool hasLeaveUpdate = leaveTimeline!.any(
(event) => event.type == EventTypes.SpaceChild, (event) => event.type == EventTypes.SpaceChild,
) ?? );
false;
return hasJoinUpdate || hasLeaveUpdate; return hasJoinUpdate || hasLeaveUpdate;
} }

View file

@ -9,13 +9,11 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/course_chats/activity_template_chat_list_item.dart';
import 'package:fluffychat/pangea/course_chats/course_chats_page.dart'; import 'package:fluffychat/pangea/course_chats/course_chats_page.dart';
import 'package:fluffychat/pangea/course_chats/unjoined_chat_list_item.dart'; import 'package:fluffychat/pangea/course_chats/unjoined_chat_list_item.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart'; import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart';
import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart'; import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart';
import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart';
@ -41,63 +39,19 @@ class CourseChatsView extends StatelessWidget {
return CoursePlanBuilder( return CoursePlanBuilder(
courseId: room.coursePlan?.uuid, courseId: room.coursePlan?.uuid,
onLoaded: (course) { onLoaded: controller.setCourse,
controller.setCourse(course);
final topic = room.ownCurrentTopic(course);
if (topic != null) controller.setSelectedTopicId(topic.uuid);
},
builder: (context, courseController) { builder: (context, courseController) {
final CourseTopicModel? topic = controller.selectedTopic;
final List<String> activityIds = topic?.activityIds ?? [];
return StreamBuilder( return StreamBuilder(
stream: room.client.onSync.stream stream: room.client.onSync.stream
.where((s) => s.hasRoomUpdate) .where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1)), .rateLimit(const Duration(seconds: 1)),
builder: (context, snapshot) { builder: (context, snapshot) {
final childrenIds = room.spaceChildren final joinedChats = controller.joinedChats;
.map((c) => c.roomId) final joinedSessions = controller.joinedActivities();
.whereType<String>()
.toSet();
final joinedChats = []; final discoveredGroupChats = controller.discoveredGroupChats;
final joinedSessions = []; final discoveredSessions =
final joinedRooms = room.client.rooms controller.discoveredActivities().entries.toList();
.where((room) => childrenIds.remove(room.id))
.where((room) => !room.isHiddenRoom)
.toList();
for (final joinedRoom in joinedRooms) {
if (joinedRoom.isActivitySession) {
String? activityId = joinedRoom.activityPlan?.activityId;
if (activityId == null && joinedRoom.isActivityRoomType) {
activityId = joinedRoom.roomType!.split(":").last;
}
if (topic == null || activityIds.contains(activityId)) {
joinedSessions.add(joinedRoom);
}
} else {
joinedChats.add(joinedRoom);
}
}
final discoveredGroupChats = [];
final discoveredSessions = [];
final discoveredChildren =
controller.discoveredChildren ?? <SpaceRoomsChunk>[];
for (final child in discoveredChildren) {
final roomType = child.roomType;
if (roomType?.startsWith(PangeaRoomTypes.activitySession) ==
true) {
if (activityIds.contains(roomType!.split(":").last)) {
discoveredSessions.add(child);
}
} else {
discoveredGroupChats.add(child);
}
}
final isColumnMode = FluffyThemes.isColumnMode(context); final isColumnMode = FluffyThemes.isColumnMode(context);
return Padding( return Padding(
@ -113,7 +67,7 @@ class CourseChatsView extends StatelessWidget {
joinedSessions.length + joinedSessions.length +
discoveredGroupChats.length + discoveredGroupChats.length +
discoveredSessions.length + discoveredSessions.length +
6, 7,
itemBuilder: (context, i) { itemBuilder: (context, i) {
// courses chats title // courses chats title
if (i == 0) { if (i == 0) {
@ -178,92 +132,6 @@ class CourseChatsView extends StatelessWidget {
} }
i -= discoveredGroupChats.length; i -= discoveredGroupChats.length;
if (i == 0) {
if (room.coursePlan == null ||
(courseController.course == null &&
!courseController.loading)) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 12.0,
),
child: courseController.loading
? LinearProgressIndicator(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
)
: topic != null
? Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
MouseRegion(
cursor: controller.canMoveLeft
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: controller.canMoveLeft
? controller.moveLeft
: null,
child: Opacity(
opacity: controller.canMoveLeft
? 1.0
: 0.3,
child: const Icon(
Icons.arrow_left,
size: 24.0,
),
),
),
),
if (topic.location != null)
Row(
spacing: 6.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.location_on,
size: 24.0,
),
Text(
topic.location!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
MouseRegion(
cursor: controller.canMoveRight
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: controller.canMoveRight
? controller.moveRight
: null,
child: Opacity(
opacity: controller.canMoveRight
? 1.0
: 0.3,
child: const Icon(
Icons.arrow_right,
size: 24.0,
),
),
),
),
],
)
: const SizedBox(),
);
}
i--;
if (i == 0) { if (i == 0) {
return joinedSessions.isEmpty && discoveredSessions.isEmpty return joinedSessions.isEmpty && discoveredSessions.isEmpty
? ListTile( ? ListTile(
@ -279,6 +147,23 @@ class CourseChatsView extends StatelessWidget {
} }
i--; i--;
if (i == 0) {
return joinedSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).myActivities,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// joined activity sessions // joined activity sessions
if (i < joinedSessions.length) { if (i < joinedSessions.length) {
final joinedRoom = joinedSessions[i]; final joinedRoom = joinedSessions[i];
@ -290,17 +175,58 @@ class CourseChatsView extends StatelessWidget {
context, context,
), ),
activeChat: controller.widget.activeChat == joinedRoom.id, activeChat: controller.widget.activeChat == joinedRoom.id,
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
trailing: joinedRoom.activityIsFinished
? SizedBox(
height: 24.0,
width: 54.0,
child: ElevatedButton(
onPressed: () =>
controller.onChatTap(joinedRoom),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(0),
),
child: Text(
L10n.of(context).results,
style: const TextStyle(
fontSize: 12,
),
),
),
)
: null,
); );
} }
i -= joinedSessions.length; i -= joinedSessions.length;
if (i == 0) {
return discoveredSessions.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.only(
top: 20.0,
bottom: 4.0,
),
child: Text(
L10n.of(context).openToJoin,
style: const TextStyle(fontSize: 12.0),
textAlign: TextAlign.center,
),
);
}
i--;
// unjoined activity sessions // unjoined activity sessions
if (i < discoveredSessions.length) { if (i < discoveredSessions.length) {
return UnjoinedChatListItem( final activity = discoveredSessions[i].key;
chunk: discoveredSessions[i], final sessions = discoveredSessions[i].value;
onTap: () => controller.joinChildRoom( return ActivityTemplateChatListItem(
discoveredSessions[i], space: room,
), joinActivity: controller.joinChildRoom,
activity: activity,
sessions: sessions,
); );
} }
i -= discoveredSessions.length; i -= discoveredSessions.length;

View file

@ -0,0 +1,13 @@
import 'package:matrix/matrix.dart';
class ExtendedSpaceRoomsChunk {
final SpaceRoomsChunk chunk;
final String activityId;
final List<String> userIds;
ExtendedSpaceRoomsChunk({
required this.chunk,
required this.activityId,
required this.userIds,
});
}

View file

@ -0,0 +1,62 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/widgets/avatar.dart';
class OpenRolesIndicator extends StatelessWidget {
final int totalSlots;
final List<String> userIds;
final Room? space;
const OpenRolesIndicator({
super.key,
required this.totalSlots,
required this.userIds,
this.space,
});
@override
Widget build(BuildContext context) {
final remainingSlots = max(
0,
totalSlots - userIds.length,
);
final spaceParticipants = space?.getParticipants() ?? [];
return Row(
spacing: 2.0,
children: [
...userIds.map((u) {
final user = spaceParticipants.firstWhereOrNull(
(p) => p.id == u,
);
return Avatar(
mxContent: user?.avatarUrl,
userId: user?.calcDisplayname() ?? u.localpart ?? u,
size: 16,
);
}),
...List.generate(remainingSlots, (_) {
return Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 8,
backgroundColor: Theme.of(context).colorScheme.primary,
),
CircleAvatar(
radius: 7,
backgroundColor: Theme.of(context).colorScheme.surface,
),
],
);
}),
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_media_repo.dart'; import 'package:fluffychat/pangea/course_plans/course_media_repo.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart'; import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
@ -57,6 +58,16 @@ class CoursePlanModel {
int get totalActivities => int get totalActivities =>
loadedTopics.fold(0, (sum, topic) => sum + topic.activityIds.length); loadedTopics.fold(0, (sum, topic) => sum + topic.activityIds.length);
ActivityPlanModel? activityById(String activityID) {
for (final topic in loadedTopics) {
final activity = topic.activityById(activityID);
if (activity != null) {
return activity;
}
}
return null;
}
/// Deserialize from JSON /// Deserialize from JSON
factory CoursePlanModel.fromJson(Map<String, dynamic> json) { factory CoursePlanModel.fromJson(Map<String, dynamic> json) {
return CoursePlanModel( return CoursePlanModel(

View file

@ -1,3 +1,4 @@
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
@ -28,11 +29,52 @@ extension CoursePlanRoomExtension on Room {
userID, userID,
); );
if (event == null) return null; if (event == null) return null;
return CourseUserState.fromJson(event.content);
try {
return CourseUserState.fromJson(event.content);
} catch (e) {
return null;
}
} }
CourseUserState? get _ownCourseState => _courseUserState(client.userID!); CourseUserState? get _ownCourseState => _courseUserState(client.userID!);
Map<String, CourseUserState> get allCourseUserStates {
final content = states[PangeaEventTypes.courseUser];
if (content == null || content.isEmpty) return {};
return Map<String, CourseUserState>.fromEntries(
content.entries.map(
(e) {
try {
return MapEntry(
e.key,
CourseUserState.fromJson(e.value.content),
);
} catch (e) {
return null;
}
},
).whereType<MapEntry<String, CourseUserState>>(),
);
}
Set<String> openSessions(String activityId) {
final Set<String> sessions = {};
final Set<String> childIds =
spaceChildren.map((child) => child.roomId).whereType<String>().toSet();
for (final userState in allCourseUserStates.values) {
final activitySessions = userState.joinedActivities[activityId]?.toSet();
if (activitySessions == null) continue;
sessions.addAll(
activitySessions.intersection(childIds),
);
}
return sessions;
}
int numOpenSessions(String activityId) => openSessions(activityId).length;
bool hasCompletedActivity( bool hasCompletedActivity(
String userID, String userID,
String activityID, String activityID,
@ -44,14 +86,14 @@ extension CoursePlanRoomExtension on Room {
bool _hasCompletedTopic( bool _hasCompletedTopic(
String userID, String userID,
String topicID, CourseTopicModel topic,
CoursePlanModel course, CoursePlanModel course,
) { ) {
final state = _courseUserState(userID); final state = _courseUserState(userID);
if (state == null) return false; if (state == null) return false;
final topicIndex = course.loadedTopics.indexWhere( final topicIndex = course.loadedTopics.indexWhere(
(t) => t.uuid == topicID, (t) => t.uuid == topic.uuid,
); );
if (topicIndex == -1) { if (topicIndex == -1) {
@ -61,7 +103,7 @@ extension CoursePlanRoomExtension on Room {
final activityIds = course.loadedTopics[topicIndex].loadedActivities final activityIds = course.loadedTopics[topicIndex].loadedActivities
.map((a) => a.activityId) .map((a) => a.activityId)
.toList(); .toList();
return state.completedActivities(topicID).toSet().containsAll(activityIds); return state.completedActivities.toSet().containsAll(activityIds);
} }
CourseTopicModel? currentTopic( CourseTopicModel? currentTopic(
@ -69,10 +111,9 @@ extension CoursePlanRoomExtension on Room {
CoursePlanModel course, CoursePlanModel course,
) { ) {
if (coursePlan == null) return null; if (coursePlan == null) return null;
final topicIDs = course.loadedTopics.map((t) => t.uuid).toList(); if (course.loadedTopics.isEmpty) return null;
if (topicIDs.isEmpty) return null;
final index = topicIDs.indexWhere( final index = course.loadedTopics.indexWhere(
(t) => !_hasCompletedTopic(userID, t, course), (t) => !_hasCompletedTopic(userID, t, course),
); );
@ -87,10 +128,9 @@ extension CoursePlanRoomExtension on Room {
CoursePlanModel course, CoursePlanModel course,
) { ) {
if (coursePlan == null) return -1; if (coursePlan == null) return -1;
final topicIDs = course.loadedTopics.map((t) => t.uuid).toList(); if (course.loadedTopics.isEmpty) return -1;
if (topicIDs.isEmpty) return -1;
final index = topicIDs.indexWhere( final index = course.loadedTopics.indexWhere(
(t) => !_hasCompletedTopic(userID, t, course), (t) => !_hasCompletedTopic(userID, t, course),
); );
@ -104,7 +144,9 @@ extension CoursePlanRoomExtension on Room {
for (final child in spaceChildren) { for (final child in spaceChildren) {
if (child.roomId == null) continue; if (child.roomId == null) continue;
final room = client.getRoomById(child.roomId!); final room = client.getRoomById(child.roomId!);
if (room?.activityId == activityId && !room!.isHiddenActivityRoom) { if (room?.membership == Membership.join &&
room?.activityId == activityId &&
!room!.isHiddenActivityRoom) {
return room.id; return room.id;
} }
} }
@ -130,16 +172,36 @@ extension CoursePlanRoomExtension on Room {
return topicUserMap; return topicUserMap;
} }
Future<void> finishCourseActivity( Future<void> joinCourseActivity(
String activityID, String activityID,
String topicID, String roomID,
) async { ) async {
CourseUserState? state = _ownCourseState; CourseUserState? state = _ownCourseState;
state ??= CourseUserState( state ??= CourseUserState(
userID: client.userID!, userID: client.userID!,
completedActivities: {}, completedActivities: {},
joinActivities: {},
); );
state.completeActivity(activityID, topicID); state.joinActivity(activityID, roomID);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.courseUser,
client.userID!,
state.toJson(),
);
}
Future<void> finishCourseActivity(
String activityID,
String roomID,
) async {
CourseUserState? state = _ownCourseState;
state ??= CourseUserState(
userID: client.userID!,
completedActivities: {},
joinActivities: {},
);
state.completeActivity(activityID, roomID);
await client.setRoomStateWithKey( await client.setRoomStateWithKey(
id, id,
PangeaEventTypes.courseUser, PangeaEventTypes.courseUser,
@ -156,7 +218,7 @@ extension CoursePlanRoomExtension on Room {
creationContent: { creationContent: {
'type': "${PangeaRoomTypes.activitySession}:${activity.activityId}", 'type': "${PangeaRoomTypes.activitySession}:${activity.activityId}",
}, },
visibility: Visibility.private, visibility: sdk.Visibility.private,
name: activity.title, name: activity.title,
initialState: [ initialState: [
StateEvent( StateEvent(
@ -198,6 +260,11 @@ extension CoursePlanRoomExtension on Room {
if (pangeaSpaceParents.isEmpty) { if (pangeaSpaceParents.isEmpty) {
await client.waitForRoomInSync(roomID); await client.waitForRoomInSync(roomID);
} }
await joinCourseActivity(
activity.activityId,
roomID,
);
return roomID; return roomID;
} }
} }

View file

@ -1,3 +1,5 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/common/config/environment.dart'; import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart'; import 'package:fluffychat/pangea/course_plans/course_activity_repo.dart';
@ -56,6 +58,11 @@ class CourseTopicModel {
Future<List<ActivityPlanModel>> fetchActivities() => Future<List<ActivityPlanModel>> fetchActivities() =>
CourseActivityRepo.get(uuid, activityIds); CourseActivityRepo.get(uuid, activityIds);
ActivityPlanModel? activityById(String activityId) =>
loadedActivities.firstWhereOrNull(
(activity) => activity.activityId == activityId,
);
/// Deserialize from JSON /// Deserialize from JSON
factory CourseTopicModel.fromJson(Map<String, dynamic> json) { factory CourseTopicModel.fromJson(Map<String, dynamic> json) {
return CourseTopicModel( return CourseTopicModel(

View file

@ -1,46 +1,67 @@
class CourseUserState { class CourseUserState {
final String userID; final String userID;
// Map of activityIds to list of roomIds
final Map<String, List<String>> _completedActivities; final Map<String, List<String>> _completedActivities;
final Map<String, List<String>> _joinedActivities;
CourseUserState({ CourseUserState({
required this.userID, required this.userID,
required Map<String, List<String>> completedActivities, required Map<String, List<String>> completedActivities,
}) : _completedActivities = completedActivities; required Map<String, List<String>> joinActivities,
}) : _completedActivities = completedActivities,
_joinedActivities = joinActivities;
void joinActivity(
String activityID,
String roomID,
) {
_joinedActivities[activityID] ??= [];
_joinedActivities[activityID]!.add(roomID);
}
void completeActivity( void completeActivity(
String activityID, String activityID,
String topicID, String roomID,
) { ) {
_completedActivities[topicID] ??= []; _completedActivities[activityID] ??= [];
if (!_completedActivities[topicID]!.contains(activityID)) { _completedActivities[activityID]!.add(roomID);
_completedActivities[topicID]!.add(activityID);
}
} }
List<String> completedActivities(String topicID) { Map<String, List<String>> get joinedActivities => _joinedActivities;
return _completedActivities[topicID] ?? [];
} List<String> get completedActivities => _completedActivities.keys.toList();
List<String> get joinedActivityRooms =>
_joinedActivities.values.expand((e) => e).toList();
bool hasCompletedActivity( bool hasCompletedActivity(
String activityID, String activityID,
) { ) {
return _completedActivities.values.any( return _completedActivities.containsKey(activityID);
(activities) => activities.contains(activityID),
);
} }
factory CourseUserState.fromJson(Map<String, dynamic> json) { factory CourseUserState.fromJson(Map<String, dynamic> json) {
final Map<String, List<String>> activities = {}; final activityEntry = json['comp_act_by_topic'];
final activityEntry = final joinEntry = json['join_act_by_topic'];
(json['comp_act_by_topic'] as Map<String, dynamic>?) ?? {};
for (final entry in activityEntry.entries) { final Map<String, List<String>> activityMap = {};
activities[entry.key] = List<String>.from(entry.value); if (activityEntry != null) {
activityEntry.forEach((key, value) {
activityMap[key] = List<String>.from(value);
});
}
final Map<String, List<String>> joinMap = {};
if (joinEntry != null) {
joinEntry.forEach((key, value) {
joinMap[key] = List<String>.from(value);
});
} }
return CourseUserState( return CourseUserState(
userID: json['user_id'], userID: json['user_id'],
completedActivities: activities, completedActivities: activityMap,
joinActivities: joinMap,
); );
} }
@ -48,6 +69,7 @@ class CourseUserState {
return { return {
'user_id': userID, 'user_id': userID,
'comp_act_by_topic': _completedActivities, 'comp_act_by_topic': _completedActivities,
'join_act_by_topic': _joinedActivities,
}; };
} }
} }

View file

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
class MapClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final double w = size.width;
final double h = size.height;
final Path path = Path();
path.moveTo(0, 0);
path.lineTo(0, h * 0.15);
path.lineTo(w * 0.33, 0);
path.lineTo(w * 0.66, h * 0.15);
path.lineTo(w, 0);
path.lineTo(w, h * 0.85);
path.lineTo(w * 0.66, h);
path.lineTo(w * 0.33, h * 0.85);
path.lineTo(0, h);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

View file

@ -182,6 +182,8 @@ class CourseSettings extends StatelessWidget {
activityId, activityId,
); );
final activity = topic.loadedActivities[index];
return Padding( return Padding(
padding: const EdgeInsets.only(right: 24.0), padding: const EdgeInsets.only(right: 24.0),
child: MouseRegion( child: MouseRegion(
@ -195,13 +197,15 @@ class CourseSettings extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
ActivitySuggestionCard( ActivitySuggestionCard(
activity: topic.loadedActivities[index], activity: activity,
width: isColumnMode ? 160.0 : 120.0, width: isColumnMode ? 160.0 : 120.0,
height: isColumnMode ? 280.0 : 200.0, height: isColumnMode ? 280.0 : 200.0,
fontSize: isColumnMode ? 20.0 : 12.0, fontSize: isColumnMode ? 20.0 : 12.0,
fontSizeSmall: fontSizeSmall:
isColumnMode ? 12.0 : 8.0, isColumnMode ? 12.0 : 8.0,
iconSize: isColumnMode ? 12.0 : 8.0, iconSize: isColumnMode ? 12.0 : 8.0,
openSessions:
room.numOpenSessions(activityId),
), ),
if (complete) if (complete)
Container( Container(

View file

@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat_list/navi_rail_item.dart'; import 'package:fluffychat/pages/chat_list/navi_rail_item.dart';
import 'package:fluffychat/pangea/chat_list/utils/chat_list_handle_space_tap.dart'; import 'package:fluffychat/pangea/chat_list/utils/chat_list_handle_space_tap.dart';
import 'package:fluffychat/pangea/course_plans/map_clipper.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart';
@ -177,6 +177,8 @@ class SpacesNavigationRail extends StatelessWidget {
toolTip: displayname, toolTip: displayname,
isSelected: activeSpaceId == space.id, isSelected: activeSpaceId == space.id,
// #Pangea // #Pangea
backgroundColor: Colors.transparent,
borderRadius: BorderRadius.circular(0),
// onTap: () => onGoToSpaceId(rootSpaces[i].id), // onTap: () => onGoToSpaceId(rootSpaces[i].id),
onTap: () { onTap: () {
final room = client.getRoomById(rootSpaces[i].id); final room = client.getRoomById(rootSpaces[i].id);
@ -194,20 +196,32 @@ class SpacesNavigationRail extends StatelessWidget {
// Pangea# // Pangea#
unreadBadgeFilter: (room) => unreadBadgeFilter: (room) =>
spaceChildrenIds.contains(room.id), spaceChildrenIds.contains(room.id),
icon: Avatar( // #Pangea
mxContent: rootSpaces[i].avatar, // icon: Avatar(
name: displayname, // mxContent: rootSpaces[i].avatar,
border: BorderSide( // name: displayname,
width: 1, // border: BorderSide(
color: Theme.of(context).dividerColor, // width: 1,
// color: Theme.of(context).dividerColor,
// ),
// borderRadius: BorderRadius.circular(
// AppConfig.borderRadius / 2,
// ),
// ),
icon: ClipPath(
clipper: MapClipper(),
child: Avatar(
mxContent: rootSpaces[i].avatar,
name: displayname,
border: BorderSide(
width: 1,
color: Theme.of(context).dividerColor,
),
borderRadius: BorderRadius.circular(0),
size: width - (isColumnMode ? 32.0 : 24.0),
), ),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 2,
),
// #Pangea
size: width - (isColumnMode ? 32.0 : 24.0),
// Pangea#
), ),
// Pangea#
); );
}, },
), ),