From 2efd61bccd31a130e8be5da9185bbb2d0f9cccd6 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:20:52 -0400 Subject: [PATCH] 4450 find course button overflow with german l1 (#4488) * fix: text overflow in add course page German buttons * feat: show warning popup when user selects course plan in new course page if user is already in a course with the same ID * use firstWhereOrNull for room search --- lib/l10n/intl_en.arb | 4 +- .../course_search_provider.dart | 65 ---- .../courses/course_plan_client_extension.dart | 10 + lib/pangea/login/pages/add_course_page.dart | 21 +- lib/pangea/login/pages/new_course_page.dart | 311 +++++++++++++----- 5 files changed, 261 insertions(+), 150 deletions(-) delete mode 100644 lib/pangea/course_creation/course_search_provider.dart create mode 100644 lib/pangea/course_plans/courses/course_plan_client_extension.dart diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 96cc4d7e9..7abea706e 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5311,5 +5311,7 @@ "numSavedActivities": "Number of saved activities", "saveActivityTitle": "Save activity", "saveActivityDesc": "Good job! Save this activity for later review and practice", - "levelInfoTooltip": "Here you can see all the points you’ve earned and how!" + "levelInfoTooltip": "Here you can see all the points you’ve earned and how!", + "alreadyInCourseWithID": "You are already in a course with this plan. Do you want to create a course with the same plan, or go to the existing course?", + "goToExistingCourse": "Go to existing course" } diff --git a/lib/pangea/course_creation/course_search_provider.dart b/lib/pangea/course_creation/course_search_provider.dart deleted file mode 100644 index fafbe59f5..000000000 --- a/lib/pangea/course_creation/course_search_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/pangea/common/utils/error_handler.dart'; -import 'package:fluffychat/pangea/course_plans/courses/course_filter.dart'; -import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; -import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; -import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; - -mixin CourseSearchProvider on State { - bool loading = true; - Object? error; - - Map courses = {}; - LanguageModel? targetLanguageFilter; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback( - (_) => _loadCourses(), - ); - } - - CourseFilter get _filter { - return CourseFilter( - targetLanguage: targetLanguageFilter, - ); - } - - void setTargetLanguageFilter(LanguageModel? language, {bool reload = true}) { - if (targetLanguageFilter?.langCodeShort == language?.langCodeShort) return; - setState(() => targetLanguageFilter = language); - if (reload) _loadCourses(); - } - - Future _loadCourses() async { - try { - setState(() { - loading = true; - error = null; - }); - final resp = await CoursePlansRepo.searchByFilter(filter: _filter); - courses = resp.coursePlans; - if (courses.isEmpty) { - ErrorHandler.logError( - e: "No courses found", - data: { - 'filter': _filter.toJson(), - }, - ); - } - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - 'filter': _filter.toJson(), - }, - ); - error = e; - } finally { - if (mounted) setState(() => loading = false); - } - } -} diff --git a/lib/pangea/course_plans/courses/course_plan_client_extension.dart b/lib/pangea/course_plans/courses/course_plan_client_extension.dart new file mode 100644 index 000000000..f24514ebd --- /dev/null +++ b/lib/pangea/course_plans/courses/course_plan_client_extension.dart @@ -0,0 +1,10 @@ +import 'package:collection/collection.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/course_plans/courses/course_plan_room_extension.dart'; + +extension CoursePlanClientExtension on Client { + Room? getRoomByCourseId(String courseId) => rooms.firstWhereOrNull( + (room) => room.coursePlan?.uuid == courseId, + ); +} diff --git a/lib/pangea/login/pages/add_course_page.dart b/lib/pangea/login/pages/add_course_page.dart index 221b85fc4..81b77d8b8 100644 --- a/lib/pangea/login/pages/add_course_page.dart +++ b/lib/pangea/login/pages/add_course_page.dart @@ -72,7 +72,12 @@ class AddCoursePage extends StatelessWidget { BlendMode.srcIn, ), ), - Text(L10n.of(context).joinCourseWithCode), + Flexible( + child: Text( + L10n.of(context).joinCourseWithCode, + textAlign: TextAlign.center, + ), + ), ], ), ), @@ -93,7 +98,12 @@ class AddCoursePage extends StatelessWidget { size: 24.0, color: theme.colorScheme.onPrimaryContainer, ), - Text(L10n.of(context).joinPublicCourse), + Flexible( + child: Text( + L10n.of(context).joinPublicCourse, + textAlign: TextAlign.center, + ), + ), ], ), ), @@ -118,7 +128,12 @@ class AddCoursePage extends StatelessWidget { BlendMode.srcIn, ), ), - Text(L10n.of(context).startOwn), + Flexible( + child: Text( + L10n.of(context).startOwn, + textAlign: TextAlign.center, + ), + ), ], ), ), diff --git a/lib/pangea/login/pages/new_course_page.dart b/lib/pangea/login/pages/new_course_page.dart index d01dc3ce2..c250ecaa9 100644 --- a/lib/pangea/login/pages/new_course_page.dart +++ b/lib/pangea/login/pages/new_course_page.dart @@ -1,17 +1,28 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:async/async.dart'; import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/l10n/l10n.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_language_filter.dart'; -import 'package:fluffychat/pangea/course_creation/course_search_provider.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_filter.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plan_client_extension.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plan_model.dart'; +import 'package:fluffychat/pangea/course_plans/courses/course_plans_repo.dart'; +import 'package:fluffychat/pangea/course_plans/courses/get_localized_courses_response.dart'; +import 'package:fluffychat/pangea/learning_settings/models/language_model.dart'; import 'package:fluffychat/pangea/login/pages/add_course_page.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; class NewCoursePage extends StatefulWidget { @@ -30,15 +41,139 @@ class NewCoursePage extends StatefulWidget { State createState() => NewCoursePageState(); } -class NewCoursePageState extends State - with CourseSearchProvider { +class NewCoursePageState extends State { + final ValueNotifier?> _courses = + ValueNotifier(null); + + final ValueNotifier _targetLanguageFilter = + ValueNotifier(null); + @override void initState() { super.initState(); - final target = MatrixState.pangeaController.languageController.userL2; - if (target != null) { - setTargetLanguageFilter(target, reload: false); + _targetLanguageFilter.value = + MatrixState.pangeaController.languageController.userL2; + + _loadCourses(); + } + + CourseFilter get _filter { + return CourseFilter( + targetLanguage: _targetLanguageFilter.value, + ); + } + + void _setTargetLanguageFilter(LanguageModel? language) { + if (_targetLanguageFilter.value?.langCodeShort == language?.langCodeShort) { + return; + } + + _targetLanguageFilter.value = language; + _loadCourses(); + } + + Future _loadCourses() async { + try { + _courses.value = null; + final resp = await CoursePlansRepo.searchByFilter(filter: _filter); + _courses.value = Result.value(resp); + if (resp.coursePlans.isEmpty) { + ErrorHandler.logError( + e: "No courses found", + data: { + 'filter': _filter.toJson(), + }, + ); + } + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + 'filter': _filter.toJson(), + }, + ); + _courses.value = Result.error(e); + } + } + + Future _onSelect(CoursePlanModel course) async { + final existingRoom = + Matrix.of(context).client.getRoomByCourseId(course.uuid); + + if (existingRoom == null) { + context.go( + widget.spaceId != null + ? '/rooms/spaces/${widget.spaceId}/addcourse/${course.uuid}' + : '/${widget.route}/course/own/${course.uuid}', + ); + return; + } + + final action = await showAdaptiveDialog( + barrierDismissible: true, + context: context, + builder: (context) => AlertDialog.adaptive( + title: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256), + child: Center( + child: Text( + course.title, + textAlign: TextAlign.center, + ), + ), + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 256, maxHeight: 256), + child: Text( + L10n.of(context).alreadyInCourseWithID, + textAlign: TextAlign.center, + ), + ), + actions: [ + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(0), + bigButtons: true, + child: Text(L10n.of(context).createCourse), + ), + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(1), + bigButtons: true, + child: Text( + L10n.of(context).goToExistingCourse, + ), + ), + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).pop(null), + bigButtons: true, + child: Text( + L10n.of(context).cancel, + ), + ), + ], + ), + ); + + if (action == 0) { + context.go( + widget.spaceId != null + ? '/rooms/spaces/${widget.spaceId}/addcourse/${course.uuid}' + : '/${widget.route}/course/own/${course.uuid}', + ); + } else if (action == 1) { + if (existingRoom.isSpace) { + context.go('/rooms/spaces/${existingRoom.id}'); + } else { + ErrorHandler.logError( + e: "Existing course room is not a space", + data: { + 'roomId': existingRoom.id, + 'courseId': course.uuid, + }, + ); + context.go('/rooms/${existingRoom.id}'); + } } } @@ -46,7 +181,6 @@ class NewCoursePageState extends State Widget build(BuildContext context) { final theme = Theme.of(context); final spaceId = widget.spaceId; - final courseEntries = courses.entries.toList(); return Scaffold( appBar: AppBar( title: Row( @@ -88,9 +222,14 @@ class NewCoursePageState extends State runSpacing: 8.0, alignment: WrapAlignment.start, children: [ - CourseLanguageFilter( - value: targetLanguageFilter, - onChanged: setTargetLanguageFilter, + ValueListenableBuilder( + valueListenable: _targetLanguageFilter, + builder: (context, value, __) { + return CourseLanguageFilter( + value: _targetLanguageFilter.value, + onChanged: _setTargetLanguageFilter, + ); + }, ), ], ), @@ -99,8 +238,14 @@ class NewCoursePageState extends State ), const SizedBox(height: 20.0), ], - loading || error != null || courses.isEmpty - ? Center( + ValueListenableBuilder( + valueListenable: _courses, + builder: (context, value, __) { + final loading = value == null; + if (loading || + value.isError || + value.result!.coursePlans.isEmpty) { + return Center( child: Padding( padding: const EdgeInsets.all(32.0), child: loading @@ -142,84 +287,88 @@ class NewCoursePageState extends State ), ), ), - ) - : Expanded( - child: ListView.separated( - separatorBuilder: (context, index) => - const SizedBox(height: 10.0), - itemCount: courseEntries.length, - itemBuilder: (context, index) { - final course = courseEntries[index].value; - final courseId = courseEntries[index].key; - return Material( - type: MaterialType.transparency, - child: InkWell( - onTap: () => context.go( - spaceId != null - ? '/rooms/spaces/$spaceId/addcourse/$courseId' - : '/${widget.route}/course/own/$courseId', + ); + } + + final courseEntries = + value.result!.coursePlans.entries.toList(); + + return Expanded( + child: ListView.separated( + separatorBuilder: (context, index) => + const SizedBox(height: 10.0), + itemCount: courseEntries.length, + itemBuilder: (context, index) { + final course = courseEntries[index].value; + final courseId = courseEntries[index].key; + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => _onSelect(course), + 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( - courseId, - iconSize: 12.0, - fontSize: 12.0, - ), - Text( - course.description, - style: theme.textTheme.bodyMedium, - ), - ], - ), + ), + ], + ), + CourseInfoChips( + courseId, + iconSize: 12.0, + fontSize: 12.0, + ), + Text( + course.description, + style: theme.textTheme.bodyMedium, + ), + ], ), ), - ); - }, - ), + ), + ); + }, ), + ); + }, + ), ], ), ),