feat: add ability to add course to existing space (#4037)

This commit is contained in:
ggurdin 2025-09-18 16:05:24 -04:00 committed by GitHub
parent 80080787b0
commit 1b3a97d8db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 286 additions and 169 deletions

View file

@ -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(

View file

@ -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"
}

View file

@ -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<NewCourse> createState() => NewCourseController();

View file

@ -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,

View file

@ -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<SelectedCourse> {
context.go("/rooms/spaces/${room.id}/details");
}
Future<void> 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);
}

View file

@ -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<void> 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,
),
),
),
],
],
),
),
),
],

View file

@ -147,6 +147,17 @@ extension CoursePlanRoomExtension on Room {
return null;
}
Future<void> addCourseToSpace(String courseId) async {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.coursePlan,
"",
{
"uuid": courseId,
},
);
}
Future<Map<String, List<User>>> topicsToUsers(CoursePlanModel course) async {
final Map<String, List<User>> topicUserMap = {};
final users = await requestParticipants(

View file

@ -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);