From 1b3a97d8dbc571cb72bbd77e994a2fb5ef3d413f Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:05:24 -0400 Subject: [PATCH] feat: add ability to add course to existing space (#4037) --- lib/config/routes.dart | 25 ++ lib/l10n/intl_en.arb | 7 +- .../course_creation/new_course_page.dart | 6 +- .../course_creation/new_course_view.dart | 12 +- .../course_creation/selected_course_page.dart | 31 +- .../course_creation/selected_course_view.dart | 330 +++++++++--------- .../course_plan_room_extension.dart | 11 + .../course_settings/course_settings.dart | 33 +- 8 files changed, 286 insertions(+), 169 deletions(-) diff --git a/lib/config/routes.dart b/lib/config/routes.dart index f09185ff6..f463abae9 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -681,6 +681,31 @@ abstract class AppRoutes { routes: roomDetailsRoutes('spaceid'), ), ...roomDetailsRoutes('spaceid'), + GoRoute( + path: 'addcourse', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + NewCourse( + spaceId: state.pathParameters['spaceid']!, + ), + ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: ':courseId', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + SelectedCourse( + state.pathParameters['courseId']!, + spaceId: state.pathParameters['spaceid']!, + ), + ), + redirect: loggedOutRedirect, + ), + ], + ), GoRoute( path: 'activity/:activityid', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 388c4687f..2115b30a1 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5100,7 +5100,7 @@ "activityFinishedMessage": "All Finished!", "endForAll": "End for all", "newCourse": "New Course", - "newCourseSubtitle": "Which course template would you like to use?", + "newCourseSubtitle": "Which course plan plan would you like to use?", "failedToLoadCourses": "Failed to load courses", "numModules": "{num} modules", "@numModules": { @@ -5134,7 +5134,7 @@ "createGroupChatDesc": "Whereas activity sessions start and end, group chats will stay open for routine communication.", "deleteDesc": "Only admins can delete a course. This is a destructive action which removes all users and deletes all selected chats within the course. Proceed with caution.", "failedToLoadCourseInfo": "Failed to load course information", - "noCourseFound": "No course information found", + "noCourseFound": "Oh, this course needs a plan!\n\nCourse plans are a sequence of topics and conversation activities.", "additionalParticipants": "+ {num} others", "@additionalParticipants": { "type": "int", @@ -5250,5 +5250,6 @@ "pingSent": "🔔 Course ping sent! 🔔", "courseTitle": "Course title", "courseDesc": "Course description", - "courseSavedSuccessfully": "Course saved successfully" + "courseSavedSuccessfully": "Course saved successfully", + "addCoursePlan": "Add a course plan" } diff --git a/lib/pangea/course_creation/new_course_page.dart b/lib/pangea/course_creation/new_course_page.dart index 52634d021..b8348ed76 100644 --- a/lib/pangea/course_creation/new_course_page.dart +++ b/lib/pangea/course_creation/new_course_page.dart @@ -7,7 +7,11 @@ import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_en import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; class NewCourse extends StatefulWidget { - const NewCourse({super.key}); + final String? spaceId; + const NewCourse({ + super.key, + this.spaceId, + }); @override State createState() => NewCourseController(); diff --git a/lib/pangea/course_creation/new_course_view.dart b/lib/pangea/course_creation/new_course_view.dart index 78bba5d70..0ee9a1943 100644 --- a/lib/pangea/course_creation/new_course_view.dart +++ b/lib/pangea/course_creation/new_course_view.dart @@ -23,10 +23,15 @@ class NewCourseView extends StatelessWidget { const double descFontSize = 12.0; const double iconSize = 12.0; + final spaceId = controller.widget.spaceId; return Scaffold( appBar: AppBar( - title: Text(L10n.of(context).newCourse), + title: Text( + spaceId != null + ? L10n.of(context).addCoursePlan + : L10n.of(context).newCourse, + ), ), body: Padding( padding: const EdgeInsets.all(12.0), @@ -41,6 +46,7 @@ class NewCourseView extends StatelessWidget { L10n.of(context).newCourseSubtitle, style: const TextStyle( fontSize: titleFontSize, + fontStyle: FontStyle.italic, ), ), Padding( @@ -125,7 +131,9 @@ class NewCourseView extends StatelessWidget { child: CoursePlanTile( course: controller.courses[index], onTap: () => context.go( - "/rooms/communities/newcourse/${controller.courses[index].uuid}", + spaceId != null + ? "/rooms/spaces/$spaceId/addcourse/${controller.courses[index].uuid}" + : "/rooms/communities/newcourse/${controller.courses[index].uuid}", ), titleFontSize: titleFontSize, chipFontSize: descFontSize, diff --git a/lib/pangea/course_creation/selected_course_page.dart b/lib/pangea/course_creation/selected_course_page.dart index 21ccbc65b..14e562a74 100644 --- a/lib/pangea/course_creation/selected_course_page.dart +++ b/lib/pangea/course_creation/selected_course_page.dart @@ -10,13 +10,15 @@ import 'package:matrix/matrix.dart' as sdk; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/course_creation/selected_course_view.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/events/constants/pangea_event_types.dart'; import 'package:fluffychat/pangea/spaces/utils/client_spaces_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; class SelectedCourse extends StatefulWidget { final String courseId; - const SelectedCourse(this.courseId, {super.key}); + final String? spaceId; + const SelectedCourse(this.courseId, {super.key, this.spaceId}); @override SelectedCourseController createState() => SelectedCourseController(); @@ -70,9 +72,28 @@ class SelectedCourseController extends State { context.go("/rooms/spaces/${room.id}/details"); } + Future addCourseToSpace(CoursePlanModel course) async { + if (widget.spaceId == null) return; + final space = Matrix.of(context).client.getRoomById(widget.spaceId!); + + if (space == null) { + throw Exception("Space not found"); + } + + await space.addCourseToSpace(widget.courseId); + + if (space.name.isEmpty) { + await space.setName(course.title); + } + + if (space.topic.isEmpty) { + await space.setDescription(course.description); + } + + if (!mounted) return; + context.go("/rooms/spaces/${space.id}/details"); + } + @override - Widget build(BuildContext context) => SelectedCourseView( - courseId: widget.courseId, - launchCourse: launchCourse, - ); + Widget build(BuildContext context) => SelectedCourseView(this); } diff --git a/lib/pangea/course_creation/selected_course_view.dart b/lib/pangea/course_creation/selected_course_view.dart index d6eadeb7e..320b30a02 100644 --- a/lib/pangea/course_creation/selected_course_view.dart +++ b/lib/pangea/course_creation/selected_course_view.dart @@ -5,20 +5,18 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/l10n/l10n.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_creation/selected_course_page.dart'; import 'package:fluffychat/pangea/course_plans/course_plan_builder.dart'; -import 'package:fluffychat/pangea/course_plans/course_plan_model.dart'; import 'package:fluffychat/pangea/course_plans/map_clipper.dart'; import 'package:fluffychat/pangea/course_settings/pin_clipper.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; class SelectedCourseView extends StatelessWidget { - final String courseId; - final Future Function(CoursePlanModel course) launchCourse; - const SelectedCourseView({ + final SelectedCourseController controller; + const SelectedCourseView( + this.controller, { super.key, - required this.courseId, - required this.launchCourse, }); @override @@ -32,171 +30,182 @@ class SelectedCourseView extends StatelessWidget { const double mediumIconSize = 16.0; const double smallIconSize = 12.0; + final spaceId = controller.widget.spaceId; + return Scaffold( appBar: AppBar( - title: Text(L10n.of(context).newCourse), + title: Text( + spaceId != null + ? L10n.of(context).addCoursePlan + : L10n.of(context).newCourse, + ), ), body: CoursePlanBuilder( - courseId: courseId, + courseId: controller.widget.courseId, onNotFound: () => context.go("/rooms/communities/newcourse"), - builder: (context, controller) { - final course = controller.course; + builder: (context, courseController) { + final course = courseController.course; return MaxWidthBody( showBorder: false, withScrolling: false, maxWidth: 500.0, child: course == null ? const Center(child: CircularProgressIndicator.adaptive()) - : Stack( - alignment: Alignment.bottomCenter, + : Column( children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: ListView.builder( - itemCount: course.loadedTopics.length + 2, - itemBuilder: (context, index) { - if (index == 0) { - return Column( - spacing: 8.0, - children: [ - ClipPath( - clipper: MapClipper(), - child: ImageByUrl( - imageUrl: course.imageUrl, - width: 100.0, - borderRadius: BorderRadius.circular(0.0), - replacement: Container( + Expanded( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: ListView.builder( + itemCount: course.loadedTopics.length + 2, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + spacing: 8.0, + children: [ + ClipPath( + clipper: MapClipper(), + child: ImageByUrl( + imageUrl: course.imageUrl, width: 100.0, - height: 100.0, - decoration: BoxDecoration( - color: theme.colorScheme.secondary, + borderRadius: + BorderRadius.circular(0.0), + replacement: Container( + width: 100.0, + height: 100.0, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + ), ), ), ), - ), - Text( - course.title, - style: const TextStyle( - fontSize: titleFontSize, + Text( + course.title, + style: const TextStyle( + fontSize: titleFontSize, + ), ), - ), - Text( - course.description, - style: - const TextStyle(fontSize: descFontSize), - ), - CourseInfoChips( - course, - fontSize: descFontSize, - iconSize: smallIconSize, - ), - Padding( - padding: const EdgeInsets.only( - top: 4.0, - bottom: 8.0, + Text( + course.description, + style: const TextStyle( + fontSize: descFontSize, + ), ), - child: Row( - spacing: 4.0, - children: [ - const Icon( - Icons.map, - size: largeIconSize, - ), - Text( - L10n.of(context).coursePlan, - style: const TextStyle( - fontSize: titleFontSize, + CourseInfoChips( + course, + fontSize: descFontSize, + iconSize: smallIconSize, + ), + Padding( + padding: const EdgeInsets.only( + top: 4.0, + bottom: 8.0, + ), + child: Row( + spacing: 4.0, + children: [ + const Icon( + Icons.map, + size: largeIconSize, ), - ), - ], + Text( + L10n.of(context).coursePlan, + style: const TextStyle( + fontSize: titleFontSize, + ), + ), + ], + ), ), - ), - ], - ); - } + ], + ); + } - index--; + index--; - if (index == course.loadedTopics.length) { - return const SizedBox(height: 150.0); - } + if (index == course.loadedTopics.length) { + return const SizedBox(height: 150.0); + } - final topic = course.loadedTopics[index]; - return Padding( - padding: - const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipPath( - clipper: PinClipper(), - child: ImageByUrl( - imageUrl: topic.imageUrl, - width: 45.0, - replacement: Container( + final topic = course.loadedTopics[index]; + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipPath( + clipper: PinClipper(), + child: ImageByUrl( + imageUrl: topic.imageUrl, width: 45.0, - height: 45.0, - decoration: BoxDecoration( - color: theme.colorScheme.secondary, + replacement: Container( + width: 45.0, + height: 45.0, + decoration: BoxDecoration( + color: theme.colorScheme.secondary, + ), ), ), ), - ), - Flexible( - child: Column( - spacing: 4.0, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - topic.title, - style: const TextStyle( - fontSize: titleFontSize, + Flexible( + child: Column( + spacing: 4.0, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + topic.title, + style: const TextStyle( + fontSize: titleFontSize, + ), ), - ), - Text( - topic.description, - style: const TextStyle( - fontSize: descFontSize, + Text( + topic.description, + style: const TextStyle( + fontSize: descFontSize, + ), ), - ), - Padding( - padding: const EdgeInsetsGeometry - .symmetric( - vertical: 2.0, - ), - child: Row( - spacing: 8.0, - mainAxisSize: MainAxisSize.min, - children: [ - if (topic.location != null) + Padding( + padding: const EdgeInsetsGeometry + .symmetric( + vertical: 2.0, + ), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + if (topic.location != null) + CourseInfoChip( + icon: Icons.location_on, + text: topic.location!, + fontSize: descFontSize, + iconSize: smallIconSize, + ), CourseInfoChip( - icon: Icons.location_on, - text: topic.location!, + icon: + Icons.event_note_outlined, + text: L10n.of(context) + .numActivityPlans( + topic.loadedActivities + .length, + ), fontSize: descFontSize, iconSize: smallIconSize, ), - CourseInfoChip( - icon: Icons.event_note_outlined, - text: L10n.of(context) - .numActivityPlans( - topic.loadedActivities.length, - ), - fontSize: descFontSize, - iconSize: smallIconSize, - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ), Container( @@ -246,29 +255,36 @@ class SelectedCourseView extends StatelessWidget { ), ], ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - padding: const EdgeInsets.symmetric( - vertical: 10.0, - horizontal: 16.0, + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, ), - ), - onPressed: () => showFutureLoadingDialog( - context: context, - future: () => launchCourse(course), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - L10n.of(context).createCourse, - style: const TextStyle( - fontSize: titleFontSize, + onPressed: () => showFutureLoadingDialog( + context: context, + future: () => spaceId != null + ? controller.addCourseToSpace(course) + : controller.launchCourse(course), + ), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.map_outlined), + Text( + spaceId != null + ? L10n.of(context).addCoursePlan + : L10n.of(context).createCourse, + style: const TextStyle( + fontSize: titleFontSize, + ), ), - ), - ], + ], + ), ), ), ], diff --git a/lib/pangea/course_plans/course_plan_room_extension.dart b/lib/pangea/course_plans/course_plan_room_extension.dart index 9689c5bb1..3ce2881d6 100644 --- a/lib/pangea/course_plans/course_plan_room_extension.dart +++ b/lib/pangea/course_plans/course_plan_room_extension.dart @@ -147,6 +147,17 @@ extension CoursePlanRoomExtension on Room { return null; } + Future addCourseToSpace(String courseId) async { + await client.setRoomStateWithKey( + id, + PangeaEventTypes.coursePlan, + "", + { + "uuid": courseId, + }, + ); + } + Future>> topicsToUsers(CoursePlanModel course) async { final Map> topicUserMap = {}; final users = await requestParticipants( diff --git a/lib/pangea/course_settings/course_settings.dart b/lib/pangea/course_settings/course_settings.dart index fb173f94e..2c09488e2 100644 --- a/lib/pangea/course_settings/course_settings.dart +++ b/lib/pangea/course_settings/course_settings.dart @@ -18,6 +18,7 @@ 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 { final Room room; @@ -41,7 +42,37 @@ class CourseSettings extends StatelessWidget { } if (controller.course == null) { - return Center(child: Text(L10n.of(context).noCourseFound)); + return room.canChangeStateEvent(PangeaEventTypes.coursePlan) + ? Column( + spacing: 50.0, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + L10n.of(context).noCourseFound, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primaryContainer, + foregroundColor: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + onPressed: () => + context.go("/rooms/spaces/${room.id}/addcourse"), + child: Row( + spacing: 8.0, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.map_outlined), + Text(L10n.of(context).addCoursePlan), + ], + ), + ), + ], + ) + : Center(child: Text(L10n.of(context).noCourseFound)); } final theme = Theme.of(context);