chore: use synapse room_preview to determine which activities users have completed (#4078)

This commit is contained in:
ggurdin 2025-09-22 13:14:38 -04:00 committed by GitHub
parent 8887efa22d
commit 152a716aa8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 177 additions and 242 deletions

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_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -49,14 +48,6 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
await controller.room.archiveActivity();
await MatrixState.pangeaController.putAnalytics
.sendActivityAnalytics(controller.room.id);
final courseParent = controller.room.courseParent;
if (courseParent?.coursePlan == null) return;
final activityId = controller.room.activityPlan!.activityId;
await courseParent!.finishCourseActivity(
activityId,
controller.room.id,
);
}
ActivitySummaryModel? get summary => controller.room.activitySummary;

View file

@ -71,16 +71,7 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
@override
void initState() {
super.initState();
_loadActivity();
if (courseParent != null) {
loadRoomSummaries(
courseParent!.spaceChildren
.map((c) => c.roomId)
.whereType<String>()
.toList(),
);
}
_load();
}
@override
@ -94,7 +85,7 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
}
if (oldWidget.activityId != widget.activityId) {
_loadActivity();
_load();
}
}
@ -220,34 +211,52 @@ class ActivitySessionStartController extends State<ActivitySessionStartPage>
return (activity?.req.numberOfParticipants ?? 0) - availableParticipants;
}
Future<void> _loadActivity() async {
Future<void> _load() async {
try {
setState(() {
loading = true;
error = null;
});
if (courseParent?.coursePlan != null) {
course = await CoursePlansRepo.get(courseParent!.coursePlan!.uuid);
}
final activities = await CourseActivityRepo.get(
widget.activityId,
[widget.activityId],
);
if (activities.isEmpty) {
throw Exception("Activity not found");
}
if (mounted) setState(() => activity = activities.first);
final futures = <Future>[];
futures.add(_loadSummary());
futures.add(_loadActivity());
await Future.wait(futures);
} catch (e) {
if (mounted) setState(() => error = e);
error = e;
} finally {
if (mounted) setState(() => loading = false);
if (mounted) {
setState(() => loading = false);
}
}
}
Future<void> _loadSummary() async {
if (courseParent == null) return;
await loadRoomSummaries(
courseParent!.spaceChildren
.map((c) => c.roomId)
.whereType<String>()
.toList(),
);
}
Future<void> _loadActivity() async {
if (courseParent?.coursePlan != null) {
course = await CoursePlansRepo.get(courseParent!.coursePlan!.uuid);
}
final activities = await CourseActivityRepo.get(
widget.activityId,
[widget.activityId],
);
if (activities.isEmpty) {
throw Exception("Activity not found");
}
activity = activities.first;
}
Future<void> joinActivity() async {
if (state != SessionState.selectedRole) return;
if (widget.roomId == null) {

View file

@ -1,3 +1,5 @@
// ignore_for_file: implementation_imports
import 'dart:math';
import 'package:flutter/material.dart';

View file

@ -57,7 +57,7 @@ class SelectedCourseView extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ListView.builder(
itemCount: course.loadedTopics.length + 2,
itemCount: course.loadedTopics.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return Column(
@ -122,11 +122,6 @@ class SelectedCourseView extends StatelessWidget {
}
index--;
if (index == course.loadedTopics.length) {
return const SizedBox(height: 150.0);
}
final topic = course.loadedTopics[index];
return Padding(
padding:

View file

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.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/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
@ -13,15 +17,9 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
return;
}
try {
final resp =
await Matrix.of(context).client.requestRoomSummaries(roomIds);
if (mounted) {
setState(() => roomSummaries = resp.summaries);
}
} catch (e, s) {
ErrorHandler.logError(e: e, s: s, data: {'roomIds': roomIds});
final resp = await Matrix.of(context).client.requestRoomSummaries(roomIds);
if (mounted) {
setState(() => roomSummaries = resp.summaries);
}
}
@ -48,4 +46,83 @@ mixin ActivitySummariesProvider<T extends StatefulWidget> on State<T> {
}
int numOpenSessions(String activityId) => openSessions(activityId).length;
Set<String> _completedActivities(String userID) {
if (roomSummaries == null || roomSummaries!.isEmpty) return {};
return roomSummaries!.values
.where(
(entry) => entry.activityRoles.roles.values.any(
(v) => v.userId == userID && v.isArchived,
),
)
.map((e) => e.activityPlan.activityId)
.toSet();
}
bool hasCompletedActivity(
String userID,
String activityID,
) {
final completed = _completedActivities(userID);
return completed.contains(activityID);
}
bool _hasCompletedTopic(
String userID,
CourseTopicModel topic,
CoursePlanModel course,
) {
final topicIndex = course.loadedTopics.indexWhere(
(t) => t.uuid == topic.uuid,
);
if (topicIndex == -1) {
throw Exception('Topic not found');
}
final topicActivities = course.loadedTopics[topicIndex].loadedActivities;
final topicActivityIds = topicActivities.map((a) => a.activityId).toSet();
final numTwoPersonActivities =
topicActivities.where((a) => a.req.numberOfParticipants <= 2).length;
final completedTopicActivities =
_completedActivities(userID).intersection(topicActivityIds);
return completedTopicActivities.length >= numTwoPersonActivities;
}
int currentTopicIndex(
String userID,
CoursePlanModel course,
) {
if (course.loadedTopics.isEmpty) return -1;
for (int i = 0; i < course.loadedTopics.length; i++) {
if (!_hasCompletedTopic(userID, course.loadedTopics[i], course)) {
return i;
}
}
return 0;
}
Future<Map<String, List<User>>> topicsToUsers(
Room room,
CoursePlanModel course,
) async {
final Map<String, List<User>> topicUserMap = {};
final users = await room.requestParticipants(
[Membership.join, Membership.invite, Membership.knock],
false,
true,
);
for (final user in users) {
if (user.id == BotName.byEnvironment) continue;
final topicIndex = currentTopicIndex(user.id, course);
if (topicIndex != -1) {
final topicID = course.loadedTopics[topicIndex].uuid;
topicUserMap.putIfAbsent(topicID, () => []).add(user);
}
}
return topicUserMap;
}
}

View file

@ -5,13 +5,9 @@ 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/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat/constants/default_power_level.dart';
import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_event.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_topic_model.dart';
import 'package:fluffychat/pangea/course_plans/course_user_event.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
@ -23,96 +19,6 @@ extension CoursePlanRoomExtension on Room {
return CoursePlanEvent.fromJson(event.content);
}
CourseUserState? _courseUserState(String userID) {
final event = getState(
PangeaEventTypes.courseUser,
userID,
);
if (event == null) return null;
try {
return CourseUserState.fromJson(event.content);
} catch (e) {
return null;
}
}
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>>(),
);
}
bool hasCompletedActivity(
String userID,
String activityID,
) {
final state = _courseUserState(userID);
if (state == null) return false;
return state.hasCompletedActivity(activityID);
}
bool _hasCompletedTopic(
String userID,
CourseTopicModel topic,
CoursePlanModel course,
) {
final state = _courseUserState(userID);
if (state == null) return false;
final topicIndex = course.loadedTopics.indexWhere(
(t) => t.uuid == topic.uuid,
);
if (topicIndex == -1) {
throw Exception('Topic not found');
}
final topicActivities = course.loadedTopics[topicIndex].loadedActivities;
final topicActivityIds = topicActivities.map((a) => a.activityId).toSet();
final numTwoPersonActivities =
topicActivities.where((a) => a.req.numberOfParticipants <= 2).length;
final completedTopicActivities =
state.completedActivities.intersection(topicActivityIds);
return completedTopicActivities.length >= numTwoPersonActivities;
}
int currentTopicIndex(
String userID,
CoursePlanModel course,
) {
if (coursePlan == null) return -1;
if (course.loadedTopics.isEmpty) return -1;
for (int i = 0; i < course.loadedTopics.length; i++) {
if (!_hasCompletedTopic(userID, course.loadedTopics[i], course)) {
return i;
}
}
return 0;
}
int ownCurrentTopicIndex(CoursePlanModel course) =>
currentTopicIndex(client.userID!, course);
String? activeActivityRoomId(String activityId) {
for (final child in spaceChildren) {
if (child.roomId == null) continue;
@ -137,43 +43,6 @@ extension CoursePlanRoomExtension on Room {
);
}
Future<Map<String, List<User>>> topicsToUsers(CoursePlanModel course) async {
final Map<String, List<User>> topicUserMap = {};
final users = await requestParticipants(
[Membership.join, Membership.invite, Membership.knock],
false,
true,
);
for (final user in users) {
if (user.id == BotName.byEnvironment) continue;
final topicIndex = currentTopicIndex(user.id, course);
if (topicIndex != -1) {
final topicID = course.loadedTopics[topicIndex].uuid;
topicUserMap.putIfAbsent(topicID, () => []).add(user);
}
}
return topicUserMap;
}
Future<void> finishCourseActivity(
String activityID,
String roomID,
) async {
CourseUserState? state = _ownCourseState;
state ??= CourseUserState(
userID: client.userID!,
completedActivities: {},
);
state.completeActivity(activityID, roomID);
await client.setRoomStateWithKey(
id,
PangeaEventTypes.courseUser,
client.userID!,
state.toJson(),
);
}
Future<String> launchActivityRoom(
ActivityPlanModel activity,
ActivityRole? role,

View file

@ -1,50 +0,0 @@
class CourseUserState {
final String userID;
// Map of activityIds to list of roomIds
final Map<String, List<String>> _completedActivities;
CourseUserState({
required this.userID,
required Map<String, List<String>> completedActivities,
}) : _completedActivities = completedActivities;
void completeActivity(
String activityID,
String roomID,
) {
_completedActivities[activityID] ??= [];
_completedActivities[activityID]!.add(roomID);
}
Set<String> get completedActivities => _completedActivities.keys.toSet();
bool hasCompletedActivity(
String activityID,
) {
return _completedActivities.containsKey(activityID);
}
factory CourseUserState.fromJson(Map<String, dynamic> json) {
final activityEntry = json['comp_act_by_topic'];
final Map<String, List<String>> activityMap = {};
if (activityEntry != null) {
activityEntry.forEach((key, value) {
activityMap[key] = List<String>.from(value);
});
}
return CourseUserState(
userID: json['user_id'],
completedActivities: activityMap,
);
}
Map<String, dynamic> toJson() {
return {
'user_id': userID,
'comp_act_by_topic': _completedActivities,
};
}
}

View file

@ -14,13 +14,14 @@ import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card.
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/common/widgets/url_image_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_info_chip_widget.dart';
import 'package:fluffychat/pangea/course_plans/activity_summaries_provider.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_settings/pin_clipper.dart';
import 'package:fluffychat/pangea/course_settings/topic_participant_list.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
class CourseSettings extends StatelessWidget {
class CourseSettings extends StatefulWidget {
final Room room;
final CoursePlanController controller;
const CourseSettings(
@ -29,13 +30,50 @@ class CourseSettings extends StatelessWidget {
required this.room,
});
@override
State<CourseSettings> createState() => CourseSettingsState();
}
class CourseSettingsState extends State<CourseSettings>
with ActivitySummariesProvider {
CoursePlanController get controller => widget.controller;
Room get room => widget.room;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadSummaries();
}
Future<void> _loadSummaries() async {
try {
setState(() {
_loading = true;
_error = null;
});
await loadRoomSummaries(
room.spaceChildren.map((c) => c.roomId).whereType<String>().toList(),
);
} catch (e) {
_error = e.toString();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
if (controller.loading) {
if (_loading || controller.loading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
if (_error != null || controller.error != null) {
return Center(
child: ErrorIndicator(message: L10n.of(context).failedToLoadCourseInfo),
);
@ -82,20 +120,20 @@ class CourseSettings extends StatelessWidget {
final double iconSize = isColumnMode ? 16.0 : 12.0;
final course = controller.course!;
final currentTopicIndex = room.currentTopicIndex(
final topicIndex = currentTopicIndex(
room.client.userID!,
course,
);
return FutureBuilder(
future: room.topicsToUsers(course),
future: topicsToUsers(room, course),
builder: (context, snapshot) {
final topicsToUsers = snapshot.data ?? {};
return Column(
spacing: isColumnMode ? 40.0 : 36.0,
mainAxisSize: MainAxisSize.min,
children: course.loadedTopics.mapIndexed((index, topic) {
final unlocked = index <= currentTopicIndex;
final unlocked = index <= topicIndex;
final usersInTopic = topicsToUsers[topic.uuid] ?? [];
final activities = topic.loadedActivities;
activities.sort(
@ -207,6 +245,7 @@ class CourseSettings extends StatelessWidget {
child: TopicActivitiesList(
room: room,
activities: activities,
controller: this,
),
),
],
@ -223,10 +262,13 @@ class CourseSettings extends StatelessWidget {
class TopicActivitiesList extends StatefulWidget {
final Room room;
final List<ActivityPlanModel> activities;
final CourseSettingsState controller;
const TopicActivitiesList({
super.key,
required this.room,
required this.activities,
required this.controller,
});
@override
State<TopicActivitiesList> createState() => TopicActivitiesListState();
@ -255,7 +297,7 @@ class TopicActivitiesListState extends State<TopicActivitiesList> {
itemBuilder: (context, index) {
final activityId = widget.activities[index].activityId;
final complete = widget.room.hasCompletedActivity(
final complete = widget.controller.hasCompletedActivity(
widget.room.client.userID!,
activityId,
);