From 66ac13f3bfc9dcbe378ddb0123fa1a8d0778c61a Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Thu, 27 Mar 2025 14:20:31 -0400 Subject: [PATCH] feat: featured activities page and new activity planner navigation (#2242) --- .../activity_planner/activity_plan_card.dart | 29 +- .../activity_planner/activity_plan_model.dart | 18 +- .../activity_planner_page.dart | 417 ++++++------------ .../activity_planner_page_appbar.dart | 74 ++++ .../bookmarked_activities_repo.dart | 10 +- .../bookmarked_activity_list.dart | 66 +++ ...view.dart => generated_activity_list.dart} | 132 +++--- .../activity_planner/new_activity_form.dart | 193 ++++++++ .../activity_suggestion_card.dart | 21 + .../activity_suggestion_carousel.dart | 3 + .../activity_suggestions_area.dart | 11 +- 11 files changed, 585 insertions(+), 389 deletions(-) create mode 100644 lib/pangea/activity_planner/activity_planner_page_appbar.dart create mode 100644 lib/pangea/activity_planner/bookmarked_activity_list.dart rename lib/pangea/activity_planner/{activity_list_view.dart => generated_activity_list.dart} (58%) create mode 100644 lib/pangea/activity_planner/new_activity_form.dart diff --git a/lib/pangea/activity_planner/activity_plan_card.dart b/lib/pangea/activity_planner/activity_plan_card.dart index 24571d1f8..5cad6cc5a 100644 --- a/lib/pangea/activity_planner/activity_plan_card.dart +++ b/lib/pangea/activity_planner/activity_plan_card.dart @@ -19,6 +19,7 @@ import 'package:fluffychat/widgets/future_loading_dialog.dart'; class ActivityPlanCard extends StatefulWidget { final ActivityPlanModel activity; final Room? room; + final VoidCallback onChange; final ValueChanged onEdit; final double maxWidth; final String? avatarURL; @@ -28,6 +29,7 @@ class ActivityPlanCard extends StatefulWidget { super.key, required this.activity, required this.room, + required this.onChange, required this.onEdit, this.maxWidth = 400, this.avatarURL, @@ -47,6 +49,7 @@ class ActivityPlanCardState extends State { final TextEditingController _newVocabController = TextEditingController(); final FocusNode _vocabFocusNode = FocusNode(); + String? _avatarURL; Uint8List? _avatar; String? _filename; @@ -60,6 +63,7 @@ class ActivityPlanCardState extends State { _instructionsController = TextEditingController(text: _tempActivity.instructions); _filename = widget.initialFilename; + _avatarURL = widget.avatarURL ?? widget.activity.imageURL; } static const double itemPadding = 12; @@ -81,13 +85,10 @@ class ActivityPlanCardState extends State { learningObjective: _learningObjectiveController.text, instructions: _instructionsController.text, vocab: _tempActivity.vocab, + imageURL: _avatarURL, ); - final activityWithBookmarkId = await _addBookmark(updatedActivity); - - // need to save in the repo as well - widget.onEdit(activityWithBookmarkId); - + widget.onEdit(updatedActivity); setState(() { _isEditing = false; }); @@ -99,16 +100,22 @@ class ActivityPlanCardState extends State { ErrorHandler.logError(e: e, s: stack, data: activity.toJson()); return activity; // Return the original activity in case of error }).whenComplete(() { - setState(() {}); + if (mounted) { + setState(() {}); + widget.onChange(); + } }); Future _removeBookmark() => - BookmarkedActivitiesRepo.remove(widget.activity.bookmarkId!) + BookmarkedActivitiesRepo.remove(widget.activity.bookmarkId) .catchError((e, stack) { debugger(when: kDebugMode); ErrorHandler.logError(e: e, s: stack, data: widget.activity.toJson()); }).whenComplete(() { - setState(() {}); + if (mounted) { + setState(() {}); + widget.onChange(); + } }); void _addVocab() { @@ -148,7 +155,7 @@ class ActivityPlanCardState extends State { await widget.room?.sendActivityPlan( widget.activity, avatar: _avatar, - avatarURL: widget.avatarURL, + avatarURL: _avatarURL, filename: _filename, ); @@ -179,12 +186,12 @@ class ActivityPlanCardState extends State { ), clipBehavior: Clip.hardEdge, alignment: Alignment.center, - child: widget.avatarURL != null || _avatar != null + child: _avatarURL != null || _avatar != null ? ClipRRect( child: _avatar == null ? CachedNetworkImage( fit: BoxFit.cover, - imageUrl: widget.avatarURL!, + imageUrl: _avatarURL!, placeholder: (context, url) { return const Center( child: CircularProgressIndicator(), diff --git a/lib/pangea/activity_planner/activity_plan_model.dart b/lib/pangea/activity_planner/activity_plan_model.dart index 4f7143040..762683d32 100644 --- a/lib/pangea/activity_planner/activity_plan_model.dart +++ b/lib/pangea/activity_planner/activity_plan_model.dart @@ -10,7 +10,6 @@ class ActivityPlanModel { String instructions; List vocab; String? imageURL; - String? bookmarkId; ActivityPlanModel({ required this.req, @@ -19,7 +18,6 @@ class ActivityPlanModel { required this.instructions, required this.vocab, this.imageURL, - this.bookmarkId, }); factory ActivityPlanModel.fromJson(Map json) { @@ -31,7 +29,6 @@ class ActivityPlanModel { vocab: List.from( json[ModelKey.activityPlanVocab].map((vocab) => Vocab.fromJson(vocab)), ), - bookmarkId: json[ModelKey.activityPlanBookmarkId], imageURL: json[ModelKey.activityPlanImageURL], ); } @@ -67,10 +64,6 @@ class ActivityPlanModel { return markdown; } - bool get isBookmarked { - return bookmarkId != null; - } - @override bool operator ==(Object other) { if (identical(this, other)) return true; @@ -90,8 +83,15 @@ class ActivityPlanModel { title.hashCode ^ learningObjective.hashCode ^ instructions.hashCode ^ - Object.hashAll(vocab) ^ - bookmarkId.hashCode; + Object.hashAll(vocab); + + String get bookmarkId { + return (title.hashCode ^ + learningObjective.hashCode ^ + instructions.hashCode ^ + Object.hashAll(vocab)) + .toString(); + } } class Vocab { diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart index 75e40d52a..2346d147b 100644 --- a/lib/pangea/activity_planner/activity_planner_page.dart +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -2,33 +2,30 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_list_view.dart'; import 'package:fluffychat/pangea/activity_planner/activity_mode_list_repo.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_page_appbar.dart'; +import 'package:fluffychat/pangea/activity_planner/bookmarked_activity_list.dart'; +import 'package:fluffychat/pangea/activity_planner/generated_activity_list.dart'; import 'package:fluffychat/pangea/activity_planner/learning_objective_list_repo.dart'; import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; -import 'package:fluffychat/pangea/activity_planner/suggestion_form_field.dart'; +import 'package:fluffychat/pangea/activity_planner/new_activity_form.dart'; import 'package:fluffychat/pangea/activity_planner/topic_list_repo.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; -import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; +import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_area.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; -import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; -import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart'; -import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; -import 'package:fluffychat/pangea/learning_settings/widgets/p_language_dropdown.dart'; import 'package:fluffychat/widgets/matrix.dart'; -enum _PageMode { +enum PageMode { settings, generatedActivities, + featuredActivities, savedActivities, } @@ -41,16 +38,14 @@ class ActivityPlannerPage extends StatefulWidget { } class ActivityPlannerPageState extends State { - final _formKey = GlobalKey(); + final formKey = GlobalKey(); + PageMode pageMode = PageMode.featuredActivities; - /// Index of the content to display - _PageMode _pageMode = _PageMode.settings; - - MediaEnum _selectedMedia = MediaEnum.nan; - String? _selectedLanguageOfInstructions; - String? _selectedTargetLanguage; - LanguageLevelTypeEnum? _selectedCefrLevel; - int? _selectedNumberOfParticipants; + MediaEnum selectedMedia = MediaEnum.nan; + String? selectedLanguageOfInstructions; + String? selectedTargetLanguage; + LanguageLevelTypeEnum? selectedCefrLevel; + int? selectedNumberOfParticipants; List activities = []; @@ -67,36 +62,35 @@ class ActivityPlannerPageState extends State { } if (_initialActivity == null) { - _selectedLanguageOfInstructions = + selectedLanguageOfInstructions = MatrixState.pangeaController.languageController.userL1?.langCode; - _selectedTargetLanguage = + selectedTargetLanguage = MatrixState.pangeaController.languageController.userL2?.langCode; - _selectedCefrLevel = LanguageLevelTypeEnum.a1; - _selectedNumberOfParticipants = + selectedCefrLevel = LanguageLevelTypeEnum.a1; + selectedNumberOfParticipants = max(room?.getParticipants().length ?? 1, 1); } else { - _selectedMedia = _initialActivity!.req.media; - _selectedLanguageOfInstructions = + selectedMedia = _initialActivity!.req.media; + selectedLanguageOfInstructions = _initialActivity!.req.languageOfInstructions; - _selectedTargetLanguage = _initialActivity!.req.targetLanguage; - _selectedCefrLevel = _initialActivity!.req.cefrLevel; - _selectedNumberOfParticipants = - _initialActivity!.req.numberOfParticipants; - _topicController.text = _initialActivity!.req.topic; - _objectiveController.text = _initialActivity!.req.objective; - _modeController.text = _initialActivity!.req.mode; + selectedTargetLanguage = _initialActivity!.req.targetLanguage; + selectedCefrLevel = _initialActivity!.req.cefrLevel; + selectedNumberOfParticipants = _initialActivity!.req.numberOfParticipants; + topicController.text = _initialActivity!.req.topic; + objectiveController.text = _initialActivity!.req.objective; + modeController.text = _initialActivity!.req.mode; } } - final _topicController = TextEditingController(); - final _objectiveController = TextEditingController(); - final _modeController = TextEditingController(); + final topicController = TextEditingController(); + final objectiveController = TextEditingController(); + final modeController = TextEditingController(); @override void dispose() { - _topicController.dispose(); - _objectiveController.dispose(); - _modeController.dispose(); + topicController.dispose(); + objectiveController.dispose(); + modeController.dispose(); super.dispose(); } @@ -106,27 +100,51 @@ class ActivityPlannerPageState extends State { LanguageKeys.defaultLanguage, ); - Future> get _topicItems => + Future> get topicItems => TopicListRepo.get(req); Future> get modeItems => ActivityModeListRepo.get(req); - Future> get _objectiveItems => + Future> get objectiveItems => LearningObjectiveListRepo.get(req); - Future _generateActivities() async { - _pageMode = _PageMode.generatedActivities; - setState(() {}); + void _setPageMode(PageMode? mode) { + if (mode == null) return; + setState(() => pageMode = mode); } + void setSelectedNumberOfParticipants(int? value) { + setState(() => selectedNumberOfParticipants = value); + } + + void setSelectedTargetLanguage(String? value) { + setState(() => selectedTargetLanguage = value); + } + + void setSelectedLanguageOfInstructions(String? value) { + setState(() => selectedLanguageOfInstructions = value); + } + + void setSelectedCefrLevel(LanguageLevelTypeEnum? value) { + setState(() => selectedCefrLevel = value); + } + + void setSelectedMedia(MediaEnum? value) { + if (value == null) return; + setState(() => selectedMedia = value); + } + + Future generateActivities() async => + _setPageMode(PageMode.generatedActivities); + Future _randomTopic() async { - final topics = await _topicItems; + final topics = await topicItems; return (topics..shuffle()).first.name; } Future _randomObjective() async { - final objectives = await _objectiveItems; + final objectives = await objectiveItems; return (objectives..shuffle()).first.name; } @@ -135,264 +153,103 @@ class ActivityPlannerPageState extends State { return (modes..shuffle()).first.name; } - void _randomizeSelections() async { + void randomizeSelections() async { final selectedTopic = await _randomTopic(); final selectedObjective = await _randomObjective(); final selectedMode = await _randomMode(); setState(() { - _topicController.text = selectedTopic; - _objectiveController.text = selectedObjective; - _modeController.text = selectedMode; + topicController.text = selectedTopic; + objectiveController.text = selectedObjective; + modeController.text = selectedMode; }); } // Add validation logic - String? _validateNotNull(String? value) { + String? validateNotNull(String? value) { if (value == null || value.isEmpty) { return L10n.of(context).interactiveTranslatorRequired; } return null; } + ActivityPlanRequest get planRequest => ActivityPlanRequest( + topic: topicController.text, + mode: modeController.text, + objective: objectiveController.text, + media: selectedMedia, + languageOfInstructions: selectedLanguageOfInstructions!, + targetLanguage: selectedTargetLanguage!, + cefrLevel: selectedCefrLevel!, + numberOfParticipants: selectedNumberOfParticipants!, + ); + @override Widget build(BuildContext context) { - final l10n = L10n.of(context); - return Scaffold( - appBar: AppBar( - leading: _pageMode == _PageMode.settings - ? IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), - ) - : IconButton( - onPressed: () => setState(() => _pageMode = _PageMode.settings), - icon: const Icon(Icons.arrow_back), - ), - title: _pageMode == _PageMode.savedActivities - ? Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.bookmarks), - const SizedBox(width: 8), - Text(l10n.myBookmarkedActivities), - ], - ), - ) - : Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.event_note_outlined), - const SizedBox(width: 8), - Text(l10n.activityPlannerTitle), - ], - ), - ), - actions: [ - Tooltip( - message: l10n.myBookmarkedActivities, - child: IconButton( - onPressed: () => - setState(() => _pageMode = _PageMode.savedActivities), - icon: const Icon(Icons.bookmarks), + Widget body = const SizedBox(); + switch (pageMode) { + case PageMode.settings: + body = NewActivityForm(this); + break; + case PageMode.generatedActivities: + body = GeneratedActivitiesList( + controller: this, + ); + break; + case PageMode.savedActivities: + body = BookmarkedActivitiesList( + room: room, + controller: this, + ); + break; + case PageMode.featuredActivities: + body = const Expanded( + child: SingleChildScrollView( + child: ActivitySuggestionsArea( + scrollDirection: Axis.vertical, ), ), - ], + ); + break; + } + + return Scaffold( + appBar: ActivityPlannerPageAppBar( + pageMode: pageMode, + setPageMode: _setPageMode, ), - body: _pageMode != _PageMode.settings - ? ActivityListView( - room: room, - activityPlanRequest: _PageMode.savedActivities == _pageMode - ? null - : ActivityPlanRequest( - topic: _topicController.text, - mode: _modeController.text, - objective: _objectiveController.text, - media: _selectedMedia, - languageOfInstructions: _selectedLanguageOfInstructions!, - targetLanguage: _selectedTargetLanguage!, - cefrLevel: _selectedCefrLevel!, - numberOfParticipants: _selectedNumberOfParticipants!, + body: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 800.0), + child: Column( + children: [ + if ([PageMode.featuredActivities, PageMode.savedActivities] + .contains(pageMode)) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SegmentedButton( + selected: {pageMode}, + onSelectionChanged: (modes) => _setPageMode(modes.first), + segments: const [ + ButtonSegment( + value: PageMode.featuredActivities, + label: Text("Featured activities"), + ), + ButtonSegment( + value: PageMode.savedActivities, + label: Text("Your bookmarks"), + ), + ], ), - controller: this, - ) - : Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: Form( - key: _formKey, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - const InstructionsInlineTooltip( - instructionsEnum: - InstructionsEnum.activityPlannerOverview, - ), - Row( - children: [ - Expanded( - child: Column( - children: [ - SuggestionFormField( - suggestions: _topicItems, - validator: _validateNotNull, - label: l10n.topicLabel, - placeholder: l10n.topicPlaceholder, - controller: _topicController, - ), - const SizedBox(height: 24), - SuggestionFormField( - suggestions: _objectiveItems, - validator: _validateNotNull, - label: l10n.learningObjectiveLabel, - placeholder: - l10n.learningObjectivePlaceholder, - controller: _objectiveController, - ), - const SizedBox(height: 24), - SuggestionFormField( - suggestions: modeItems, - validator: _validateNotNull, - label: l10n.modeLabel, - placeholder: l10n.modePlaceholder, - controller: _modeController, - ), - ], - ), - ), - const SizedBox(width: 4), - Column( - children: [ - IconButton( - icon: const Icon(Icons.shuffle), - onPressed: _randomizeSelections, - ), - ], - ), - ], - ), - const SizedBox(height: 24), - DropdownButtonFormField2( - customButton: CustomDropdownTextButton( - text: _selectedMedia.toDisplayCopyUsingL10n(context), - ), - menuItemStyleData: const MenuItemStyleData( - padding: EdgeInsets.zero, // Remove default padding - ), - decoration: InputDecoration(labelText: l10n.mediaLabel), - isExpanded: true, - dropdownStyleData: DropdownStyleData( - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHigh, - ), - ), - items: MediaEnum.values - .map( - (e) => DropdownMenuItem( - value: e, - child: DropdownTextButton( - text: e.toDisplayCopyUsingL10n(context), - isSelected: _selectedMedia == e, - ), - ), - ) - .toList(), - onChanged: (val) { - setState(() => _selectedMedia = val ?? MediaEnum.nan); - }, - value: _selectedMedia, - ), - const SizedBox(height: 24), - LanguageLevelDropdown( - initialLevel: _selectedCefrLevel, - onChanged: (val) => - setState(() => _selectedCefrLevel = val), - ), - const SizedBox(height: 24), - PLanguageDropdown( - languages: MatrixState - .pangeaController.pLanguageStore.baseOptions, - onChange: (val) => setState( - () => _selectedLanguageOfInstructions = val.langCode, - ), - initialLanguage: _selectedLanguageOfInstructions != null - ? PLanguageStore.byLangCode( - _selectedLanguageOfInstructions!, - ) - : MatrixState - .pangeaController.languageController.userL1, - isL2List: false, - decorationText: - L10n.of(context).languageOfInstructionsLabel, - ), - const SizedBox(height: 24), - PLanguageDropdown( - languages: MatrixState - .pangeaController.pLanguageStore.targetOptions, - onChange: (val) => setState( - () => _selectedTargetLanguage = val.langCode, - ), - initialLanguage: _selectedTargetLanguage != null - ? PLanguageStore.byLangCode( - _selectedTargetLanguage!, - ) - : MatrixState - .pangeaController.languageController.userL2, - decorationText: L10n.of(context).targetLanguageLabel, - isL2List: true, - ), - const SizedBox(height: 24), - TextFormField( - decoration: InputDecoration( - labelText: l10n.numberOfLearners, - ), - textInputAction: TextInputAction.done, - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.mustBeInteger; - } - final n = int.tryParse(value); - if (n == null || n <= 0) { - return l10n.mustBeInteger; - } - return null; - }, - onChanged: (val) => - _selectedNumberOfParticipants = int.tryParse(val), - initialValue: _selectedNumberOfParticipants?.toString(), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), - onFieldSubmitted: (_) { - if (_formKey.currentState?.validate() ?? false) { - _generateActivities(); - } - }, - ), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () { - if (_formKey.currentState?.validate() ?? false) { - _generateActivities(); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.lightbulb_outline), - const SizedBox(width: 8), - Text(l10n.generateActivitiesButton), - ], - ), - ), - ], ), - ), + ], ), - ), + body, + ], + ), + ), ); } } diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart new file mode 100644 index 000000000..0e79dff9d --- /dev/null +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; + +class ActivityPlannerPageAppBar extends StatelessWidget + implements PreferredSizeWidget { + final PageMode pageMode; + final Function(PageMode) setPageMode; + const ActivityPlannerPageAppBar({ + required this.pageMode, + required this.setPageMode, + super.key, + }); + + @override + Size get preferredSize => const Size.fromHeight(72); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return AppBar( + leading: pageMode != PageMode.settings && + pageMode != PageMode.generatedActivities + ? IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ) + : IconButton( + onPressed: () => setPageMode( + pageMode == PageMode.settings + ? PageMode.featuredActivities + : PageMode.settings, + ), + icon: const Icon(Icons.arrow_back), + ), + title: pageMode == PageMode.savedActivities + ? Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.bookmarks), + const SizedBox(width: 8), + Flexible( + child: Text(l10n.myBookmarkedActivities), + ), + ], + ), + ) + : Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.event_note_outlined), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.activityPlannerTitle, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + actions: [ + IconButton( + onPressed: () => setPageMode(PageMode.settings), + icon: const Icon(Icons.edit_outlined), + ), + ], + ); + } +} diff --git a/lib/pangea/activity_planner/bookmarked_activities_repo.dart b/lib/pangea/activity_planner/bookmarked_activities_repo.dart index d4f391ff8..cd127bab4 100644 --- a/lib/pangea/activity_planner/bookmarked_activities_repo.dart +++ b/lib/pangea/activity_planner/bookmarked_activities_repo.dart @@ -1,22 +1,17 @@ // ignore_for_file: depend_on_referenced_packages import 'package:get_storage/get_storage.dart'; -import 'package:uuid/uuid.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; class BookmarkedActivitiesRepo { - static const Uuid _uuid = Uuid(); - static final GetStorage _bookStorage = GetStorage('bookmarked_activities'); /// save an activity to the list of bookmarked activities /// returns the activity with a bookmarkId static Future save(ActivityPlanModel activity) async { - activity.bookmarkId ??= _uuid.v4(); - await _bookStorage.write( - activity.bookmarkId!, + activity.bookmarkId, activity.toJson(), ); @@ -28,8 +23,7 @@ class BookmarkedActivitiesRepo { _bookStorage.remove(bookmarkId); static bool isBookmarked(ActivityPlanModel activity) { - return activity.bookmarkId != null && - _bookStorage.read(activity.bookmarkId!) != null; + return _bookStorage.read(activity.bookmarkId) != null; } static List get() { diff --git a/lib/pangea/activity_planner/bookmarked_activity_list.dart b/lib/pangea/activity_planner/bookmarked_activity_list.dart new file mode 100644 index 000000000..6acb26c39 --- /dev/null +++ b/lib/pangea/activity_planner/bookmarked_activity_list.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; +import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; +import 'activity_plan_card.dart'; + +class BookmarkedActivitiesList extends StatefulWidget { + final Room? room; + + final ActivityPlannerPageState controller; + + const BookmarkedActivitiesList({ + super.key, + required this.room, + required this.controller, + }); + + @override + BookmarkedActivitiesListState createState() => + BookmarkedActivitiesListState(); +} + +class BookmarkedActivitiesListState extends State { + List get _bookmarkedActivities => + BookmarkedActivitiesRepo.get(); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + if (_bookmarkedActivities.isEmpty) { + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + l10n.noBookmarkedActivities, + textAlign: TextAlign.center, + ), + ), + ); + } + + return Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _bookmarkedActivities.length, + itemBuilder: (context, index) { + final activity = _bookmarkedActivities[index]; + return ActivityPlanCard( + activity: activity, + room: widget.room, + onEdit: (updatedActivity) async { + await BookmarkedActivitiesRepo.remove(activity.bookmarkId); + await BookmarkedActivitiesRepo.save(updatedActivity); + setState(() {}); + }, + onChange: () => setState(() {}), + ); + }, + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_list_view.dart b/lib/pangea/activity_planner/generated_activity_list.dart similarity index 58% rename from lib/pangea/activity_planner/activity_list_view.dart rename to lib/pangea/activity_planner/generated_activity_list.dart index 42574bf34..f5f746ebe 100644 --- a/lib/pangea/activity_planner/activity_list_view.dart +++ b/lib/pangea/activity_planner/generated_activity_list.dart @@ -2,44 +2,31 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_request.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_response.dart'; import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; -import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestions_constants.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'activity_plan_card.dart'; -class ActivityListView extends StatefulWidget { - final Room? room; - - /// if null, show saved activities - final ActivityPlanRequest? activityPlanRequest; - +class GeneratedActivitiesList extends StatefulWidget { final ActivityPlannerPageState controller; - const ActivityListView({ + const GeneratedActivitiesList({ super.key, - required this.room, - required this.activityPlanRequest, required this.controller, }); @override - ActivityListViewState createState() => ActivityListViewState(); + GeneratedActivitiesListState createState() => GeneratedActivitiesListState(); } -class ActivityListViewState extends State { +class GeneratedActivitiesListState extends State { List? _activities; - List get _bookmarkedActivities => - BookmarkedActivitiesRepo.get(); - bool _isLoading = true; Object? _error; @@ -60,20 +47,17 @@ class ActivityListViewState extends State { }); try { - if (widget.activityPlanRequest != null) { - final resp = await ActivityPlanGenerationRepo.get( - widget.activityPlanRequest!, - ); - _activities = resp.activityPlans; - } + final resp = await ActivityPlanGenerationRepo.get( + widget.controller.planRequest, + ); + _activities = resp.activityPlans; } catch (e, s) { _error = e; ErrorHandler.logError( e: e, s: s, data: { - 'room': widget.room, - 'activityPlanRequest': widget.activityPlanRequest, + 'activityPlanRequest': widget.controller.planRequest, }, ); } finally { @@ -81,30 +65,6 @@ class ActivityListViewState extends State { } } - Future _onEdit(int index, ActivityPlanModel updatedActivity) async { - // in this case we're editing an activity plan that was generated recently - // via the repo and should be updated in the cached response - if (widget.activityPlanRequest != null && _activities != null) { - final activities = _activities; - activities?[index] = updatedActivity; - ActivityPlanGenerationRepo.set( - widget.activityPlanRequest!, - ActivityPlanResponse(activityPlans: _activities!), - ); - } - - setState(() {}); - } - - Future get _selectedMode async { - final modes = await widget.controller.modeItems; - return modes.firstWhereOrNull( - (element) => - element.name.toLowerCase() == - widget.activityPlanRequest?.mode.toLowerCase(), - ); - } - Future _setModeImageURL() async { final mode = await _selectedMode; if (mode == null) return; @@ -121,11 +81,38 @@ class ActivityListViewState extends State { }); } + Future _onEdit(int index, ActivityPlanModel updatedActivity) async { + // in this case we're editing an activity plan that was generated recently + // via the repo and should be updated in the cached response + if (_activities != null) { + final activities = _activities; + activities?[index] = updatedActivity; + ActivityPlanGenerationRepo.set( + widget.controller.planRequest, + ActivityPlanResponse(activityPlans: _activities!), + ); + } + + setState(() {}); + } + + Future get _selectedMode async { + final modes = await widget.controller.modeItems; + return modes.firstWhereOrNull( + (element) => + element.name.toLowerCase() == + widget.controller.planRequest.mode.toLowerCase(), + ); + } + @override Widget build(BuildContext context) { final l10n = L10n.of(context); if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Padding( + padding: EdgeInsets.all(32.0), + child: Center(child: CircularProgressIndicator()), + ); } else if (_error != null) { return Center( child: Column( @@ -140,37 +127,24 @@ class ActivityListViewState extends State { ], ), ); - } else if (widget.activityPlanRequest != null && - (_activities == null || _activities!.isEmpty)) { + } else if (_activities == null || _activities!.isEmpty) { return Center(child: Text(l10n.noDataFound)); - } else if (widget.activityPlanRequest == null && - (_bookmarkedActivities.isEmpty)) { - return Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 200), - child: Text( - l10n.noBookmarkedActivities, - textAlign: TextAlign.center, - ), - ), - ); } else { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: widget.activityPlanRequest == null - ? _bookmarkedActivities.length - : _activities!.length, - itemBuilder: (context, index) { - return ActivityPlanCard( - activity: widget.activityPlanRequest == null - ? _bookmarkedActivities[index] - : _activities![index], - room: widget.room, - onEdit: (updatedActivity) => _onEdit(index, updatedActivity), - avatarURL: _avatarURL, - initialFilename: _filename, - ); - }, + return Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _activities!.length, + itemBuilder: (context, index) { + return ActivityPlanCard( + activity: _activities![index], + room: widget.controller.room, + onEdit: (updatedActivity) => _onEdit(index, updatedActivity), + avatarURL: _avatarURL, + initialFilename: _filename, + onChange: () => setState(() {}), + ); + }, + ), ); } } diff --git a/lib/pangea/activity_planner/new_activity_form.dart b/lib/pangea/activity_planner/new_activity_form.dart new file mode 100644 index 000000000..c05702cfb --- /dev/null +++ b/lib/pangea/activity_planner/new_activity_form.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; +import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; +import 'package:fluffychat/pangea/activity_planner/suggestion_form_field.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; +import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; +import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart'; +import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart'; +import 'package:fluffychat/pangea/learning_settings/widgets/p_language_dropdown.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class NewActivityForm extends StatelessWidget { + final ActivityPlannerPageState controller; + const NewActivityForm(this.controller, {super.key}); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Form( + key: controller.formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const InstructionsInlineTooltip( + instructionsEnum: InstructionsEnum.activityPlannerOverview, + ), + Row( + children: [ + Expanded( + child: Column( + children: [ + SuggestionFormField( + suggestions: controller.topicItems, + validator: controller.validateNotNull, + label: l10n.topicLabel, + placeholder: l10n.topicPlaceholder, + controller: controller.topicController, + ), + const SizedBox(height: 24), + SuggestionFormField( + suggestions: controller.objectiveItems, + validator: controller.validateNotNull, + label: l10n.learningObjectiveLabel, + placeholder: l10n.learningObjectivePlaceholder, + controller: controller.objectiveController, + ), + const SizedBox(height: 24), + SuggestionFormField( + suggestions: controller.modeItems, + validator: controller.validateNotNull, + label: l10n.modeLabel, + placeholder: l10n.modePlaceholder, + controller: controller.modeController, + ), + ], + ), + ), + const SizedBox(width: 4), + Column( + children: [ + IconButton( + icon: const Icon(Icons.shuffle), + onPressed: controller.randomizeSelections, + ), + ], + ), + ], + ), + const SizedBox(height: 24), + DropdownButtonFormField2( + customButton: CustomDropdownTextButton( + text: controller.selectedMedia + .toDisplayCopyUsingL10n(context), + ), + menuItemStyleData: const MenuItemStyleData( + padding: EdgeInsets.zero, // Remove default padding + ), + decoration: InputDecoration(labelText: l10n.mediaLabel), + isExpanded: true, + dropdownStyleData: DropdownStyleData( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + ), + ), + items: MediaEnum.values + .map( + (e) => DropdownMenuItem( + value: e, + child: DropdownTextButton( + text: e.toDisplayCopyUsingL10n(context), + isSelected: controller.selectedMedia == e, + ), + ), + ) + .toList(), + onChanged: controller.setSelectedMedia, + value: controller.selectedMedia, + ), + const SizedBox(height: 24), + LanguageLevelDropdown( + initialLevel: controller.selectedCefrLevel, + onChanged: controller.setSelectedCefrLevel, + ), + const SizedBox(height: 24), + PLanguageDropdown( + languages: + MatrixState.pangeaController.pLanguageStore.baseOptions, + onChange: (val) => controller + .setSelectedLanguageOfInstructions(val.langCode), + initialLanguage: controller.selectedLanguageOfInstructions != + null + ? PLanguageStore.byLangCode( + controller.selectedLanguageOfInstructions!, + ) + : MatrixState.pangeaController.languageController.userL1, + isL2List: false, + decorationText: L10n.of(context).languageOfInstructionsLabel, + ), + const SizedBox(height: 24), + PLanguageDropdown( + languages: + MatrixState.pangeaController.pLanguageStore.targetOptions, + onChange: (val) => + controller.setSelectedTargetLanguage(val.langCode), + initialLanguage: controller.selectedTargetLanguage != null + ? PLanguageStore.byLangCode( + controller.selectedTargetLanguage!, + ) + : MatrixState.pangeaController.languageController.userL2, + decorationText: L10n.of(context).targetLanguageLabel, + isL2List: true, + ), + const SizedBox(height: 24), + TextFormField( + decoration: InputDecoration( + labelText: l10n.numberOfLearners, + ), + textInputAction: TextInputAction.done, + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.mustBeInteger; + } + final n = int.tryParse(value); + if (n == null || n <= 0) { + return l10n.mustBeInteger; + } + return null; + }, + onChanged: (val) => controller + .setSelectedNumberOfParticipants(int.tryParse(val)), + initialValue: + controller.selectedNumberOfParticipants?.toString(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + onFieldSubmitted: (_) { + if (controller.formKey.currentState?.validate() ?? false) { + controller.generateActivities(); + } + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + if (controller.formKey.currentState?.validate() ?? false) { + controller.generateActivities(); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.lightbulb_outline), + const SizedBox(width: 8), + Text(l10n.generateActivitiesButton), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_suggestions/activity_suggestion_card.dart b/lib/pangea/activity_suggestions/activity_suggestion_card.dart index 66b4e113b..4b5f5c2ca 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_card.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart'; +import 'package:fluffychat/pangea/activity_planner/bookmarked_activities_repo.dart'; import 'package:fluffychat/pangea/activity_suggestions/activity_suggestion_card_row.dart'; import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; @@ -14,6 +15,8 @@ class ActivitySuggestionCard extends StatelessWidget { final double height; final double padding; + final VoidCallback onChange; + const ActivitySuggestionCard({ super.key, required this.activity, @@ -21,11 +24,14 @@ class ActivitySuggestionCard extends StatelessWidget { required this.width, required this.height, required this.padding, + required this.onChange, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isBookmarked = BookmarkedActivitiesRepo.isBookmarked(activity); + return Padding( padding: EdgeInsets.all(padding), child: PressableButton( @@ -131,6 +137,21 @@ class ActivitySuggestionCard extends StatelessWidget { ), ], ), + Positioned( + top: 4.0, + right: 4.0, + child: IconButton( + icon: Icon( + isBookmarked ? Icons.bookmark : Icons.bookmark_border, + ), + onPressed: () => isBookmarked + ? BookmarkedActivitiesRepo.remove(activity.bookmarkId) + .then((_) => onChange()) + : BookmarkedActivitiesRepo.save(activity) + .then((_) => onChange()), + iconSize: 24.0, + ), + ), ], ), ), diff --git a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart index ac595c3ab..35f37d22a 100644 --- a/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart +++ b/lib/pangea/activity_suggestions/activity_suggestion_carousel.dart @@ -197,6 +197,9 @@ class ActivitySuggestionCarouselState width: _cardWidth, height: _cardHeight, padding: 0.0, + onChange: () { + if (mounted) setState(() {}); + }, ), ), MouseRegion( diff --git a/lib/pangea/activity_suggestions/activity_suggestions_area.dart b/lib/pangea/activity_suggestions/activity_suggestions_area.dart index eaa81b058..cff67c301 100644 --- a/lib/pangea/activity_suggestions/activity_suggestions_area.dart +++ b/lib/pangea/activity_suggestions/activity_suggestions_area.dart @@ -16,7 +16,8 @@ import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_en import 'package:fluffychat/widgets/matrix.dart'; class ActivitySuggestionsArea extends StatefulWidget { - const ActivitySuggestionsArea({super.key}); + final Axis? scrollDirection; + const ActivitySuggestionsArea({super.key, this.scrollDirection}); @override ActivitySuggestionsAreaState createState() => ActivitySuggestionsAreaState(); } @@ -80,6 +81,9 @@ class ActivitySuggestionsAreaState extends State { width: cardWidth, height: cardHeight, padding: cardPadding, + onChange: () { + if (mounted) setState(() {}); + }, ); }) .cast() @@ -94,7 +98,10 @@ class ActivitySuggestionsAreaState extends State { ), ); - return _isColumnMode + final scrollDirection = widget.scrollDirection ?? + (_isColumnMode ? Axis.horizontal : Axis.vertical); + + return scrollDirection == Axis.horizontal ? ConstrainedBox( constraints: BoxConstraints(maxHeight: cardHeight + 36.0), child: Scrollbar(