feat: add selected course page for public courses, redirect there on click public course, filter out already-joined public courses (#4276)

This commit is contained in:
ggurdin 2025-10-08 10:32:05 -04:00 committed by GitHub
parent 0c5a57bc3b
commit c13c2eb1f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 158 additions and 27 deletions

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix_api_lite/generated/model.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
@ -226,10 +227,27 @@ abstract class AppRoutes {
context,
state,
const PublicTripPage(
route: 'registration',
showFilters: false,
),
);
},
routes: [
GoRoute(
path: ':courseid',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
SelectedCourse(
state.pathParameters['courseid']!,
SelectedCourseMode.join,
roomChunk: state.extra as PublicRoomsChunk?,
),
);
},
),
],
),
GoRoute(
path: 'own',
@ -252,6 +270,7 @@ abstract class AppRoutes {
state,
SelectedCourse(
state.pathParameters['courseid']!,
SelectedCourseMode.launch,
),
);
},
@ -428,9 +447,27 @@ abstract class AppRoutes {
return defaultPageBuilder(
context,
state,
const PublicTripPage(),
const PublicTripPage(
route: 'rooms',
),
);
},
routes: [
GoRoute(
path: ':courseid',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
SelectedCourse(
state.pathParameters['courseid']!,
SelectedCourseMode.join,
roomChunk: state.extra as PublicRoomsChunk?,
),
);
},
),
],
),
GoRoute(
path: 'own',
@ -450,6 +487,7 @@ abstract class AppRoutes {
state,
SelectedCourse(
state.pathParameters['courseid']!,
SelectedCourseMode.launch,
),
);
},
@ -838,6 +876,7 @@ abstract class AppRoutes {
state,
SelectedCourse(
state.pathParameters['courseId']!,
SelectedCourseMode.addToSpace,
spaceId: state.pathParameters['spaceid']!,
),
),

View file

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart' as sdk;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/course_creation/selected_course_view.dart';
@ -13,16 +14,65 @@ 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';
enum SelectedCourseMode { launch, addToSpace, join }
class SelectedCourse extends StatefulWidget {
final String courseId;
final SelectedCourseMode mode;
/// In addToSpace mode, the ID of the space to add the course to.
/// In join mode, the ID of the space to join that already has this course.
final String? spaceId;
const SelectedCourse(this.courseId, {super.key, this.spaceId});
/// In join mode, the room info for the space that already has this course.
final PublicRoomsChunk? roomChunk;
const SelectedCourse(
this.courseId,
this.mode, {
super.key,
this.spaceId,
this.roomChunk,
});
@override
SelectedCourseController createState() => SelectedCourseController();
}
class SelectedCourseController extends State<SelectedCourse> {
String get title {
switch (widget.mode) {
case SelectedCourseMode.launch:
return L10n.of(context).newCourse;
case SelectedCourseMode.addToSpace:
return L10n.of(context).addCoursePlan;
case SelectedCourseMode.join:
return L10n.of(context).joinWithClassCode;
}
}
String get buttonText {
switch (widget.mode) {
case SelectedCourseMode.launch:
return L10n.of(context).createCourse;
case SelectedCourseMode.addToSpace:
return L10n.of(context).addCoursePlan;
case SelectedCourseMode.join:
return L10n.of(context).joinWithClassCode;
}
}
Future<void> submit(CoursePlanModel course) async {
switch (widget.mode) {
case SelectedCourseMode.launch:
return launchCourse(course);
case SelectedCourseMode.addToSpace:
return addCourseToSpace(course);
case SelectedCourseMode.join:
return joinCourse(course);
}
}
Future<void> launchCourse(CoursePlanModel course) async {
final client = Matrix.of(context).client;
final Completer<String> completer = Completer<String>();
@ -55,7 +105,10 @@ class SelectedCourseController extends State<SelectedCourse> {
}
Future<void> addCourseToSpace(CoursePlanModel course) async {
if (widget.spaceId == null) return;
if (widget.spaceId == null) {
throw Exception("Space ID is null");
}
final space = Matrix.of(context).client.getRoomById(widget.spaceId!);
if (space == null) {
@ -76,6 +129,30 @@ class SelectedCourseController extends State<SelectedCourse> {
context.go("/rooms/spaces/${space.id}/details");
}
Future<void> joinCourse(CoursePlanModel course) async {
if (widget.roomChunk == null) {
throw Exception("Room chunk is null");
}
final client = Matrix.of(context).client;
final roomId = await client.joinRoom(
widget.roomChunk!.roomId,
);
final room = client.getRoomById(roomId);
if (room == null || room.membership != Membership.join) {
await client.waitForRoomInSync(roomId, join: true);
}
if (client.getRoomById(roomId) == null) {
throw Exception("Failed to join room");
}
if (mounted) {
context.go("/rooms/spaces/$roomId/details");
}
}
@override
Widget build(BuildContext context) => SelectedCourseView(this);
}

View file

@ -29,14 +29,10 @@ 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(
spaceId != null
? L10n.of(context).addCoursePlan
: L10n.of(context).newCourse,
controller.title,
),
),
body: SafeArea(
@ -63,6 +59,14 @@ class SelectedCourseView extends StatelessWidget {
child: ListView.builder(
itemCount: course.loadedTopics.length + 2,
itemBuilder: (context, index) {
String displayname = course.title;
final roomChunk = controller.widget.roomChunk;
if (roomChunk != null) {
displayname = roomChunk.name ??
roomChunk.canonicalAlias ??
L10n.of(context).emptyChat;
}
if (index == 0) {
return Column(
spacing: 8.0,
@ -70,7 +74,10 @@ class SelectedCourseView extends StatelessWidget {
ClipPath(
clipper: MapClipper(),
child: ImageByUrl(
imageUrl: course.imageUrl,
imageUrl: controller
.widget.roomChunk?.avatarUrl
?.toString() ??
course.imageUrl,
width: 100.0,
borderRadius:
BorderRadius.circular(0.0),
@ -85,7 +92,7 @@ class SelectedCourseView extends StatelessWidget {
),
),
Text(
course.title,
displayname,
style: const TextStyle(
fontSize: titleFontSize,
),
@ -278,9 +285,7 @@ class SelectedCourseView extends StatelessWidget {
),
onPressed: () => showFutureLoadingDialog(
context: context,
future: () => spaceId != null
? controller.addCourseToSpace(course)
: controller.launchCourse(course),
future: () => controller.submit(course),
),
child: Row(
spacing: 8.0,
@ -288,9 +293,7 @@ class SelectedCourseView extends StatelessWidget {
children: [
const Icon(Icons.map_outlined),
Text(
spaceId != null
? L10n.of(context).addCoursePlan
: L10n.of(context).createCourse,
controller.buttonText,
style: const TextStyle(
fontSize: titleFontSize,
),

View file

@ -62,17 +62,19 @@ class CoursePlanController extends State<CoursePlanBuilder> {
}
Future<void> _loadCourse() async {
setState(() {
loading = true;
error = null;
course = null;
});
if (widget.courseId == null) {
widget.onNotFound?.call();
setState(() => loading = false);
return;
}
try {
setState(() {
loading = true;
error = null;
course = null;
});
course = await CoursePlansRepo.get(widget.courseId!);
widget.onLoaded?.call(course!);
} catch (e) {

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
@ -17,9 +18,11 @@ import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PublicTripPage extends StatefulWidget {
final String route;
final bool showFilters;
const PublicTripPage({
super.key,
required this.route,
this.showFilters = true,
});
@ -69,7 +72,13 @@ class PublicTripPageState extends State<PublicTripPage> {
}
List<PublicCoursesChunk> get filteredCourses {
List<PublicCoursesChunk> filtered = discoveredCourses;
List<PublicCoursesChunk> filtered = discoveredCourses
.where(
(c) => !Matrix.of(context).client.rooms.any(
(r) => r.id == c.room.roomId && r.membership == Membership.join,
),
)
.toList();
if (languageLevelFilter != null) {
filtered = filtered.where(
@ -131,8 +140,6 @@ class PublicTripPageState extends State<PublicTripPage> {
'nextBatch': nextBatch,
},
);
} finally {
setState(() => loading = false);
}
try {
@ -155,7 +162,7 @@ class PublicTripPageState extends State<PublicTripPage> {
);
} finally {
if (mounted) {
setState(() {});
setState(() => loading = false);
}
}
}
@ -297,7 +304,10 @@ class PublicTripPageState extends State<PublicTripPage> {
L10n.of(context).emptyChat;
return InkWell(
onTap: () {},
onTap: () => context.go(
'/${widget.route}/course/public/${filteredCourses[index].courseId}',
extra: roomChunk,
),
borderRadius: BorderRadius.circular(12.0),
child: Container(
padding: const EdgeInsets.all(12.0),