diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 76ea5c819..e9bcb7ec3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5303,5 +5303,7 @@ "activityDropdownDesc": "When you’re 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." } diff --git a/lib/pangea/course_creation/course_search_provider.dart b/lib/pangea/course_creation/course_search_provider.dart index 5a5dba546..fd8576260 100644 --- a/lib/pangea/course_creation/course_search_provider.dart +++ b/lib/pangea/course_creation/course_search_provider.dart @@ -52,7 +52,7 @@ mixin CourseSearchProvider on State { 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; diff --git a/lib/pangea/course_plans/course_plans_repo.dart b/lib/pangea/course_plans/course_plans_repo.dart index f2bb44ef7..4fd0989b8 100644 --- a/lib/pangea/course_plans/course_plans_repo.dart +++ b/lib/pangea/course_plans/course_plans_repo.dart @@ -126,7 +126,56 @@ class CoursePlansRepo { } } - static Future> search({CourseFilter? filter}) async { + static Future> search( + List ids, { + Map? 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() + .toList(); + } + + static Future> searchByFilter({ + CourseFilter? filter, + }) async { await _courseStorage.initStorage; final Map 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() - .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() - .toList(); + return search(result.docs, where: where); } static Future clearCache() async { diff --git a/lib/pangea/guard/p_vguard.dart b/lib/pangea/guard/p_vguard.dart index 28298ff95..2e2291f5a 100644 --- a/lib/pangea/guard/p_vguard.dart +++ b/lib/pangea/guard/p_vguard.dart @@ -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 doesn’t 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; } } diff --git a/lib/pangea/login/pages/create_pangea_account_page.dart b/lib/pangea/login/pages/create_pangea_account_page.dart index 8f493c122..b52f33efd 100644 --- a/lib/pangea/login/pages/create_pangea_account_page.dart +++ b/lib/pangea/login/pages/create_pangea_account_page.dart @@ -65,6 +65,16 @@ class CreatePangeaAccountPageState extends State { } } + Future _updateTargetLanguage() async { + await MatrixState.pangeaController.userController.updateProfile( + (profile) { + profile.userSettings.targetLanguage = widget.langCode; + return profile; + }, + waitForDataInSync: true, + ); + } + Future _createUserInPangea() async { setState(() { _loadingProfile = true; @@ -73,6 +83,7 @@ class CreatePangeaAccountPageState extends State { final l2Set = await MatrixState.pangeaController.userController.isUserL2Set; if (l2Set) { + await _updateTargetLanguage(); _onProfileCreated(); return; } diff --git a/lib/pangea/login/pages/new_trip_page.dart b/lib/pangea/login/pages/new_trip_page.dart index c78d4cff9..350403661 100644 --- a/lib/pangea/login/pages/new_trip_page.dart +++ b/lib/pangea/login/pages/new_trip_page.dart @@ -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 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, + ), + ], ), ), ); diff --git a/lib/pangea/login/pages/public_trip_page.dart b/lib/pangea/login/pages/public_trip_page.dart index 00d35818e..faf305c37 100644 --- a/lib/pangea/login/pages/public_trip_page.dart +++ b/lib/pangea/login/pages/public_trip_page.dart @@ -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 { LanguageModel? instructionLanguageFilter; LanguageModel? targetLanguageFilter; + List discoveredCourses = []; + Map coursePlans = {}; + String? nextBatch; + @override void initState() { super.initState(); @@ -44,38 +57,112 @@ class PublicTripPageState extends State { } 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 get filteredCourses { + List 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 _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 { ), 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, + ), + ], + ], + ), + ), + ); + }, + ), ), - ), ], ), ), diff --git a/lib/pangea/spaces/utils/public_course_extension.dart b/lib/pangea/spaces/utils/public_course_extension.dart new file mode 100644 index 000000000..8d3722c35 --- /dev/null +++ b/lib/pangea/spaces/utils/public_course_extension.dart @@ -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 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 requestPublicCourses({ + int limit = 10, + String? since, + }) => + getPublicCourses( + limit: limit, + since: since, + ); +} + +class PublicCoursesResponse extends GetPublicRoomsResponse { + final List 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 json) { + return PublicCoursesChunk( + room: PublicRoomsChunk.fromJson(json), + courseId: json['course_id'] as String, + ); + } + + Map toJson() { + return { + 'room': room.toJson(), + 'course_id': courseId, + }; + } +} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 080de8b9d..7641ff9d7 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -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 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',