4242 change course redirect (#4245)

* feat: connect with synapse public courses endpoint

* intergate course IDs into response model

* remove course redirect, update user's l2 on return to language selection page

* display on public courses page to go to page to make your own and display on make your on page to skip to rooms
This commit is contained in:
ggurdin 2025-10-03 12:51:39 -04:00 committed by GitHub
parent e9a2d97d37
commit 5f137361bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 480 additions and 168 deletions

View file

@ -5303,5 +5303,7 @@
"activityDropdownDesc": "When youre done with this activity, click below",
"activityAnalyticsListBody": "These are your completed activities! After finishing activities, you can view them here.",
"languageMismatchTitle": "Language mismatch",
"languageMismatchDesc": "Your target language doesn't match the language of this activity. Update your target language?"
"languageMismatchDesc": "Your target language doesn't match the language of this activity. Update your target language?",
"noPublicCoursesFound": "No public courses found. Would you like to create one?",
"noCourseTemplatesFound": "We couldn't find any courses for your target language. You can chat with Pangea Bot in the meantime, and check back later for more courses."
}

View file

@ -52,7 +52,7 @@ mixin CourseSearchProvider<T extends StatefulWidget> on State<T> {
loading = true;
error = null;
});
courses = await CoursePlansRepo.search(filter: _filter);
courses = await CoursePlansRepo.searchByFilter(filter: _filter);
} catch (e, s) {
debugPrint("Failed to load courses: $e\n$s");
error = e;

View file

@ -126,7 +126,56 @@ class CoursePlansRepo {
}
}
static Future<List<CoursePlanModel>> search({CourseFilter? filter}) async {
static Future<List<CoursePlanModel>> search(
List<String> ids, {
Map<String, dynamic>? where,
}) async {
final PayloadClient payload = PayloadClient(
baseUrl: Environment.cmsApi,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final missingIds = ids
.where(
(id) => _courseStorage.read(id) == null,
)
.toList();
where ??= {};
where["id"] = {
"in": missingIds,
};
final searchResult = await payload.find(
CmsCoursePlan.slug,
CmsCoursePlan.fromJson,
page: 1,
limit: 10,
where: where,
);
final coursePlans = searchResult.docs
.map(
(cmsCoursePlan) => cmsCoursePlan.toCoursePlanModel(),
)
.toList();
for (final plan in coursePlans) {
await _setCached(plan);
}
final futures = coursePlans.map((c) => c.init());
await Future.wait(futures);
return ids
.map((id) => _getCached(id))
.whereType<CoursePlanModel>()
.toList();
}
static Future<List<CoursePlanModel>> searchByFilter({
CourseFilter? filter,
}) async {
await _courseStorage.initStorage;
final Map<String, dynamic> where = {};
@ -190,51 +239,7 @@ class CoursePlansRepo {
select: {"id": true},
);
final missingIds = result.docs
.where(
(id) => _courseStorage.read(id) == null,
)
.toList();
// If all of the returned IDs are in the cached list, ensure all of the course details have been cached, and return
if (missingIds.isEmpty) {
return result.docs
.map((id) => _getCached(id))
.whereType<CoursePlanModel>()
.toList();
}
// Else, take the list of returned course IDs minus the list of cached course IDs and
// fetch/cache the course details for each. Cache the newly returned list with all the IDs.
where["id"] = {
"in": missingIds,
};
final searchResult = await payload.find(
CmsCoursePlan.slug,
CmsCoursePlan.fromJson,
page: 1,
limit: 10,
where: where,
);
final coursePlans = searchResult.docs
.map(
(cmsCoursePlan) => cmsCoursePlan.toCoursePlanModel(),
)
.toList();
for (final plan in coursePlans) {
await _setCached(plan);
}
final futures = coursePlans.map((c) => c.init());
await Future.wait(futures);
return result.docs
.map((id) => _getCached(id))
.whereType<CoursePlanModel>()
.toList();
return search(result.docs, where: where);
}
static Future<void> clearCache() async {

View file

@ -3,9 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../common/controllers/pangea_controller.dart';
@ -55,22 +53,13 @@ class PAuthGaurd {
// If user hasn't set their L2,
// and their URL doesnt include course, redirect
final bool hasSetL2 = await pController!.userController.isUserL2Set;
final bool inCourse = Matrix.of(context).client.rooms.any(
(r) =>
r.isSpace &&
r.membership == Membership.join &&
r.coursePlan != null,
) ||
state.fullPath?.contains('course') == true;
final langCode = state.pathParameters['langcode'];
return !hasSetL2
? langCode != null
? '/registration/$langCode'
: '/registration'
: inCourse
? null
: '/registration/course';
: null;
}
/// Redirect for onboarding routes
@ -89,13 +78,6 @@ class PAuthGaurd {
return '/home';
}
final bool hasSetL2 = await pController!.userController.isUserL2Set;
final bool inCourse = Matrix.of(context).client.rooms.any(
(r) =>
r.isSpace &&
r.membership == Membership.join &&
r.coursePlan != null,
);
return hasSetL2 && inCourse ? '/rooms' : null;
return null;
}
}

View file

@ -65,6 +65,16 @@ class CreatePangeaAccountPageState extends State<CreatePangeaAccountPage> {
}
}
Future<void> _updateTargetLanguage() async {
await MatrixState.pangeaController.userController.updateProfile(
(profile) {
profile.userSettings.targetLanguage = widget.langCode;
return profile;
},
waitForDataInSync: true,
);
}
Future<void> _createUserInPangea() async {
setState(() {
_loadingProfile = true;
@ -73,6 +83,7 @@ class CreatePangeaAccountPageState extends State<CreatePangeaAccountPage> {
final l2Set = await MatrixState.pangeaController.userController.isUserL2Set;
if (l2Set) {
await _updateTargetLanguage();
_onProfileCreated();
return;
}

View file

@ -3,13 +3,14 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.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/course_plan_filter_widget.dart';
import 'package:fluffychat/pangea/course_creation/course_search_provider.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class NewTripPage extends StatefulWidget {
@ -123,86 +124,116 @@ class NewTripPageState extends State<NewTripPage> with CourseSearchProvider {
? Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: error != null
? Center(
child: ErrorIndicator(
message:
L10n.of(context).failedToLoadCourses,
child: loading
? const CircularProgressIndicator.adaptive()
: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
spacing: 12.0,
children: [
const BotFace(
expression: BotExpression.addled,
width: Avatar.defaultSize * 1.5,
),
Text(
L10n.of(context)
.noCourseTemplatesFound,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
ElevatedButton(
onPressed: () => context.go(
'/rooms',
),
style: ElevatedButton.styleFrom(
backgroundColor: theme
.colorScheme.primaryContainer,
foregroundColor: theme
.colorScheme.onPrimaryContainer,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
L10n.of(context).continueText,
),
],
),
),
],
),
),
)
: loading
? const CircularProgressIndicator.adaptive()
: Text(L10n.of(context).noCoursesFound),
),
),
)
: Expanded(
child: ListView.builder(
child: ListView.separated(
separatorBuilder: (context, index) =>
const SizedBox(height: 10.0),
itemCount: courses.length,
itemBuilder: (context, index) {
final course = courses[index];
return Padding(
padding: const EdgeInsets.only(bottom: 10.0),
child: InkWell(
onTap: () => context.go(
spaceId != null
? '/rooms/spaces/$spaceId/addcourse/${courses[index].uuid}'
: '/${widget.route}/course/own/${course.uuid}',
return InkWell(
onTap: () => context.go(
spaceId != null
? '/rooms/spaces/$spaceId/addcourse/${courses[index].uuid}'
: '/${widget.route}/course/own/${course.uuid}',
),
borderRadius: BorderRadius.circular(12.0),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
border: Border.all(
color: theme.colorScheme.primary,
),
),
borderRadius: BorderRadius.circular(12.0),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
spacing: 8.0,
children: [
ImageByUrl(
imageUrl: course.imageUrl,
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 8.0,
children: [
ImageByUrl(
imageUrl: course.imageUrl,
width: 58.0,
borderRadius:
BorderRadius.circular(10.0),
replacement: Container(
height: 58.0,
width: 58.0,
borderRadius:
BorderRadius.circular(10.0),
replacement: Container(
height: 58.0,
width: 58.0,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10.0),
color: theme.colorScheme
.surfaceContainer,
),
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10.0),
color: theme
.colorScheme.surfaceContainer,
),
),
Flexible(
child: Text(
course.title,
style: theme.textTheme.bodyLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Flexible(
child: Text(
course.title,
style: theme.textTheme.bodyLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
CourseInfoChips(
course,
iconSize: 12.0,
fontSize: 12.0,
),
Text(
course.description,
style: theme.textTheme.bodyMedium,
),
],
),
),
],
),
CourseInfoChips(
course,
iconSize: 12.0,
fontSize: 12.0,
),
Text(
course.description,
style: theme.textTheme.bodyMedium,
),
],
),
),
);

View file

@ -1,10 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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/course_plan_filter_widget.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_model.dart';
import 'package:fluffychat/pangea/course_plans/course_plans_repo.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/spaces/utils/public_course_extension.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PublicTripPage extends StatefulWidget {
@ -26,6 +35,10 @@ class PublicTripPageState extends State<PublicTripPage> {
LanguageModel? instructionLanguageFilter;
LanguageModel? targetLanguageFilter;
List<PublicCoursesChunk> discoveredCourses = [];
Map<String, CoursePlanModel> coursePlans = {};
String? nextBatch;
@override
void initState() {
super.initState();
@ -44,38 +57,112 @@ class PublicTripPageState extends State<PublicTripPage> {
}
void setLanguageLevelFilter(LanguageLevelTypeEnum? level) {
languageLevelFilter = level;
_loadCourses();
setState(() => languageLevelFilter = level);
}
void setInstructionLanguageFilter(LanguageModel? language) {
instructionLanguageFilter = language;
_loadCourses();
setState(() => instructionLanguageFilter = language);
}
void setTargetLanguageFilter(LanguageModel? language) {
targetLanguageFilter = language;
_loadCourses();
setState(() => targetLanguageFilter = language);
}
List<PublicCoursesChunk> get filteredCourses {
List<PublicCoursesChunk> filtered = discoveredCourses;
if (languageLevelFilter != null) {
filtered = filtered.where(
(chunk) {
final course = coursePlans[chunk.courseId];
if (course == null) return false;
return course.cefrLevel == languageLevelFilter;
},
).toList();
}
if (instructionLanguageFilter != null) {
filtered = filtered.where(
(chunk) {
final course = coursePlans[chunk.courseId];
if (course == null) return false;
return course.baseLanguageModel == instructionLanguageFilter;
},
).toList();
}
if (targetLanguageFilter != null) {
filtered = filtered.where(
(chunk) {
final course = coursePlans[chunk.courseId];
if (course == null) return false;
return course.targetLanguageModel == targetLanguageFilter;
},
).toList();
}
return filtered;
}
Future<void> _loadCourses() async {
// TODO: add searching of public spaces
try {
setState(() {
loading = true;
error = null;
});
await Future.delayed(const Duration(seconds: 1));
} catch (e) {
final resp = await Matrix.of(context).client.requestPublicCourses(
since: nextBatch,
);
for (final room in resp.courses) {
if (!discoveredCourses.any((e) => e.room.roomId == room.room.roomId)) {
discoveredCourses.add(room);
}
}
nextBatch = resp.nextBatch;
} catch (e, s) {
error = e;
ErrorHandler.logError(
e: e,
s: s,
data: {
'nextBatch': nextBatch,
},
);
} finally {
setState(() => loading = false);
}
try {
final searchResult = await CoursePlansRepo.search(
discoveredCourses.map((c) => c.courseId).toList(),
);
coursePlans.clear();
for (final course in searchResult) {
coursePlans[course.uuid] = course;
}
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
'discoveredCourses':
discoveredCourses.map((c) => c.courseId).toList(),
},
);
} finally {
if (mounted) {
setState(() {});
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: Row(
@ -143,20 +230,134 @@ class PublicTripPageState extends State<PublicTripPage> {
),
const SizedBox(height: 20.0),
],
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: error != null
? Center(
child: ErrorIndicator(
message: L10n.of(context).failedToLoadCourses,
if (error != null ||
(!loading && filteredCourses.isEmpty && nextBatch == null))
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
spacing: 12.0,
children: [
const BotFace(
expression: BotExpression.addled,
width: Avatar.defaultSize * 1.5,
),
Text(
L10n.of(context).noPublicCoursesFound,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
ElevatedButton(
onPressed: () => context.go(
'/registration/course/own',
),
)
: loading
? const CircularProgressIndicator.adaptive()
: Text(L10n.of(context).noCoursesFound),
style: ElevatedButton.styleFrom(
backgroundColor:
theme.colorScheme.primaryContainer,
foregroundColor:
theme.colorScheme.onPrimaryContainer,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(L10n.of(context).startOwnTrip),
],
),
),
],
),
),
)
else
Expanded(
child: ListView.separated(
itemCount: filteredCourses.length + 1,
separatorBuilder: (context, index) =>
const SizedBox(height: 10.0),
itemBuilder: (context, index) {
if (index == filteredCourses.length) {
return Center(
child: loading
? const CircularProgressIndicator.adaptive()
: nextBatch != null
? TextButton(
onPressed: _loadCourses,
child: Text(L10n.of(context).loadMore),
)
: const SizedBox(),
);
}
final roomChunk = filteredCourses[index].room;
final course =
coursePlans[filteredCourses[index].courseId];
final displayname = roomChunk.name ??
roomChunk.canonicalAlias ??
L10n.of(context).emptyChat;
return InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(12.0),
child: Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 8.0,
children: [
ImageByUrl(
imageUrl: roomChunk.avatarUrl?.toString(),
width: 58.0,
borderRadius: BorderRadius.circular(10.0),
replacement: Container(
height: 58.0,
width: 58.0,
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10.0),
color: theme
.colorScheme.surfaceContainer,
),
),
),
Flexible(
child: Text(
displayname,
style: theme.textTheme.bodyLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
if (course != null) ...[
CourseInfoChips(
course,
iconSize: 12.0,
fontSize: 12.0,
),
Text(
course.description,
style: theme.textTheme.bodyMedium,
),
],
],
),
),
);
},
),
),
),
],
),
),

View file

@ -0,0 +1,87 @@
import 'dart:convert';
import 'package:http/http.dart' hide Client;
import 'package:matrix/matrix.dart';
import 'package:matrix/matrix_api_lite/generated/api.dart';
extension PublicCourseExtension on Api {
Future<PublicCoursesResponse> getPublicCourses({
int limit = 10,
String? since,
}) async {
final requestUri = Uri(
path: '/_synapse/client/unstable/org.pangea/public_courses',
queryParameters: {
'limit': limit.toString(),
'since': since,
},
);
final request = Request('GET', baseUri!.resolveUri(requestUri));
request.headers['content-type'] = 'application/json';
request.headers['authorization'] = 'Bearer ${bearerToken!}';
final response = await httpClient.send(request);
final responseBody = await response.stream.toBytes();
final responseString = utf8.decode(responseBody);
if (response.statusCode != 200) {
throw Exception(
'HTTP error response: statusCode=${response.statusCode}, body=$responseString',
);
}
final json = jsonDecode(responseString);
return PublicCoursesResponse.fromJson(json);
}
}
extension PublicCoursesRequest on Client {
Future<PublicCoursesResponse> requestPublicCourses({
int limit = 10,
String? since,
}) =>
getPublicCourses(
limit: limit,
since: since,
);
}
class PublicCoursesResponse extends GetPublicRoomsResponse {
final List<PublicCoursesChunk> courses;
PublicCoursesResponse({
required super.chunk,
required super.nextBatch,
required super.prevBatch,
required super.totalRoomCountEstimate,
required this.courses,
});
@override
PublicCoursesResponse.fromJson(super.json)
: courses = (json['chunk'] as List)
.map((e) => PublicCoursesChunk.fromJson(e))
.toList(),
super.fromJson();
}
class PublicCoursesChunk {
final PublicRoomsChunk room;
final String courseId;
PublicCoursesChunk({
required this.room,
required this.courseId,
});
factory PublicCoursesChunk.fromJson(Map<String, dynamic> json) {
return PublicCoursesChunk(
room: PublicRoomsChunk.fromJson(json),
courseId: json['course_id'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'room': room.toJson(),
'course_id': courseId,
};
}
}

View file

@ -22,7 +22,6 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/course_plans/course_plan_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/utils/locale_provider.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart';
@ -381,18 +380,12 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
} else {
// #Pangea
final isL2Set = await pangeaController.userController.isUserL2Set;
final inCourse = client.rooms.any(
(r) =>
r.isSpace &&
r.membership == Membership.join &&
r.coursePlan != null,
);
final langCode = FluffyChatApp.router.state.pathParameters['langcode'];
final registrationRedirect =
langCode != null ? '/registration/$langCode' : '/registration';
FluffyChatApp.router.go(
state == LoginState.loggedIn
? isL2Set && inCourse
? isL2Set
? '/rooms'
: registrationRedirect
: '/home',