From b81f3841f8aea3371c7a01dbcb86ae20d769441f Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Wed, 22 Jan 2025 17:00:48 -0500 Subject: [PATCH] feat(activity_planner): full draft done (#1542) * feat(activity_planner): mvp done * refactor(activity_planner): move launch icon button to file * dev: dart formatting --------- Co-authored-by: ggurdin Co-authored-by: ggurdin <46800240+ggurdin@users.noreply.github.com> --- assets/l10n/intl_en.arb | 20 +- lib/pages/chat/chat_view.dart | 2 + .../activity_mode_list_repo.dart | 56 +++ .../activity_plan_generation_repo.dart | 122 +++++ ...activity_plan_page_launch_icon_button.dart | 30 ++ .../activity_planner/activity_plan_tile.dart | 93 ++++ .../activity_planner_page.dart | 466 ++++++++++++++++++ .../learning_objective_list_repo.dart | 57 +++ .../activity_planner/list_request_schema.dart | 37 ++ lib/pangea/activity_planner/media_enum.dart | 55 +++ .../activity_planner/topic_list_repo.dart | 56 +++ .../widgets/language_level_dropdown.dart | 1 + lib/pangea/common/constants/model_keys.dart | 3 + lib/pangea/common/network/urls.dart | 6 + .../instructions/instructions_enum.dart | 4 + .../widgets/p_language_dialog.dart | 2 + .../widgets/p_language_dropdown.dart | 72 +-- .../login/pages/user_settings_view.dart | 1 + lib/pangea/user/pages/find_partner_view.dart | 3 + macos/Runner/AppDelegate.swift | 4 + 20 files changed, 1043 insertions(+), 47 deletions(-) create mode 100644 lib/pangea/activity_planner/activity_mode_list_repo.dart create mode 100644 lib/pangea/activity_planner/activity_plan_generation_repo.dart create mode 100644 lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart create mode 100644 lib/pangea/activity_planner/activity_plan_tile.dart create mode 100644 lib/pangea/activity_planner/activity_planner_page.dart create mode 100644 lib/pangea/activity_planner/learning_objective_list_repo.dart create mode 100644 lib/pangea/activity_planner/list_request_schema.dart create mode 100644 lib/pangea/activity_planner/media_enum.dart create mode 100644 lib/pangea/activity_planner/topic_list_repo.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index d850c0306..ccc5753ee 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4749,5 +4749,23 @@ }, "notInClass": "Not in a class!", "noClassCode": "No class code!", - "chooseCorrectLabel": "Choose the correct label" + "chooseCorrectLabel": "Choose the correct label", + "activityPlannerTitle": "Activity Planner", + "topicLabel": "Topic", + "topicPlaceholder": "Choose a topic...", + "modeLabel": "Mode", + "modePlaceholder": "Choose a mode...", + "learningObjectiveLabel": "Learning Objective", + "learningObjectivePlaceholder": "Choose a learning objective...", + "mediaLabel": "Media students should share", + "languageOfInstructionsLabel": "Language of activity instructions", + "targetLanguageLabel": "Target language", + "cefrLevelLabel": "CEFR Level", + "generateActivitiesButton": "Generate Activities", + "launchActivityButton": "Launch Activity", + "image": "Image", + "video": "Video", + "voiceMessage": "Voice message", + "nan": "Not applicable", + "activityPlannerOverviewInstructionsBody": "Choose a topic, mode, learning objective and generate an activity for the chat!" } \ No newline at end of file diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 6ed4ffe87..4fc8c3709 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -16,6 +16,7 @@ import 'package:fluffychat/pages/chat/chat_emoji_picker.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart'; import 'package:fluffychat/pages/chat/reply_display.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_page_launch_icon_button.dart'; import 'package:fluffychat/pangea/analytics/controllers/put_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics/widgets/gain_points.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_floating_action_button.dart'; @@ -127,6 +128,7 @@ class ChatView extends StatelessWidget { context.go('/rooms/${controller.room.id}/search'); }, ), + ActivityPlanPageLaunchIconButton(controller: controller), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: L10n.of(context).chatDetails, diff --git a/lib/pangea/activity_planner/activity_mode_list_repo.dart b/lib/pangea/activity_planner/activity_mode_list_repo.dart new file mode 100644 index 000000000..9b4db5172 --- /dev/null +++ b/lib/pangea/activity_planner/activity_mode_list_repo.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../common/network/requests.dart'; + +class ActivityModeListRepo { + static final GetStorage _modeListStorage = GetStorage('mode_list_storage'); + + static void set( + ActivitySettingRequestSchema request, + List response, + ) { + _modeListStorage.write( + request.storageKey, + response.map((e) => e.toJson()).toList(), + ); + } + + static List fromJson(Iterable json) { + return List.from( + json.map((x) => ActivitySettingResponseSchema.fromJson(x)), + ); + } + + static Future> get( + ActivitySettingRequestSchema request, + ) async { + final cachedJson = _modeListStorage.read(request.storageKey); + if (cachedJson != null) { + return ActivityModeListRepo.fromJson(cachedJson); + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.activityModeList, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ActivityModeListRepo.fromJson(decodedBody); + + set(request, response); + + return response; + } +} diff --git a/lib/pangea/activity_planner/activity_plan_generation_repo.dart b/lib/pangea/activity_planner/activity_plan_generation_repo.dart new file mode 100644 index 000000000..574933c9e --- /dev/null +++ b/lib/pangea/activity_planner/activity_plan_generation_repo.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_planner/media_enum.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../common/network/requests.dart'; + +class ActivityPlanRequest { + final String topic; + final String mode; + final String objective; + final MediaEnum media; + final int cefrLevel; + final String languageOfInstructions; + final String targetLanguage; + final int count; + + ActivityPlanRequest({ + required this.topic, + required this.mode, + required this.objective, + required this.media, + required this.cefrLevel, + required this.languageOfInstructions, + required this.targetLanguage, + this.count = 3, + }); + + Map toJson() { + return { + 'topic': topic, + 'mode': mode, + 'objective': objective, + 'media': media.string, + 'cefr_level': cefrLanguageLevel, + 'language_of_instructions': languageOfInstructions, + 'target_language': targetLanguage, + 'count': count, + }; + } + + String get storageKey => + '$topic-$mode-$objective-${media.string}-$cefrLevel-$languageOfInstructions-$targetLanguage'; + + String get cefrLanguageLevel { + switch (cefrLevel) { + case 0: + return 'Pre-A1'; + case 1: + return 'A1'; + case 2: + return 'A2'; + case 3: + return 'B1'; + case 4: + return 'B2'; + case 5: + return 'C1'; + case 6: + return 'C2'; + default: + return 'Pre-A1'; + } + } +} + +class ActivityPlanResponse { + final List activityPlans; + + ActivityPlanResponse({required this.activityPlans}); + + factory ActivityPlanResponse.fromJson(Map json) { + return ActivityPlanResponse( + activityPlans: List.from(json['activity_plans']), + ); + } + + Map toJson() { + return { + 'activity_plans': activityPlans, + }; + } +} + +class ActivityPlanGenerationRepo { + static final GetStorage _activityPlanStorage = + GetStorage('activity_plan_storage'); + + static void set(ActivityPlanRequest request, ActivityPlanResponse response) { + _activityPlanStorage.write(request.storageKey, response.toJson()); + } + + static Future get(ActivityPlanRequest request) async { + final cachedJson = _activityPlanStorage.read(request.storageKey); + if (cachedJson != null) { + final cached = ActivityPlanResponse.fromJson(cachedJson); + + return cached; + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.activityPlanGeneration, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = ActivityPlanResponse.fromJson(decodedBody); + + set(request, response); + + return response; + } +} diff --git a/lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart b/lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart new file mode 100644 index 000000000..776eb1dff --- /dev/null +++ b/lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_planner_page.dart'; + +class ActivityPlanPageLaunchIconButton extends StatelessWidget { + const ActivityPlanPageLaunchIconButton({ + super.key, + required this.controller, + }); + + final ChatController controller; + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon(Icons.event_note_outlined), + tooltip: L10n.of(context).activityPlannerTitle, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ActivityPlannerPage(room: controller.room), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/activity_planner/activity_plan_tile.dart b/lib/pangea/activity_planner/activity_plan_tile.dart new file mode 100644 index 000000000..35d4d78cf --- /dev/null +++ b/lib/pangea/activity_planner/activity_plan_tile.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class ActivityPlanTile extends StatefulWidget { + final String activity; + final VoidCallback onLaunch; + final ValueChanged onEdit; + + const ActivityPlanTile({ + super.key, + required this.activity, + required this.onLaunch, + required this.onEdit, + }); + + @override + ActivityPlanTileState createState() => ActivityPlanTileState(); +} + +class ActivityPlanTileState extends State { + late TextEditingController _controller; + bool editMode = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.activity); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context); + return Card( + margin: const EdgeInsets.all(8.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (editMode) + TextField( + controller: _controller, + onChanged: widget.onEdit, + maxLines: null, + ) + else + Text( + widget.activity, + maxLines: null, + overflow: TextOverflow.visible, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8.0), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + onPressed: () { + setState(() { + editMode = !editMode; + }); + }, + child: Text(!editMode ? l10n.edit : l10n.cancel), + ), + const SizedBox(width: 8.0), + ElevatedButton( + onPressed: !editMode + ? widget.onLaunch + : () { + setState(() { + widget.onEdit(_controller.text); + editMode = !editMode; + }); + }, + child: Text( + !editMode ? l10n.launchActivityButton : l10n.saveChanges, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pangea/activity_planner/activity_planner_page.dart b/lib/pangea/activity_planner/activity_planner_page.dart new file mode 100644 index 000000000..29b91136f --- /dev/null +++ b/lib/pangea/activity_planner/activity_planner_page.dart @@ -0,0 +1,466 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +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_mode_list_repo.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_generation_repo.dart'; +import 'package:fluffychat/pangea/activity_planner/activity_plan_tile.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/topic_list_repo.dart'; +import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart'; +import 'package:fluffychat/pangea/common/constants/model_keys.dart'; +import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/events/models/representation_content_model.dart'; +import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/events/repo/token_api_models.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/widgets/p_language_dropdown.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +// a page to allow the user to choose settings and then generate a list of activities +// has app bar with a back button to go back to content 1 (disabled if on content 1), and a title of "Activity Planner", and close button to close the activity planner +// content 1 - settings +// content 2 - display of activities generated by the system, allowing edit and selection + +// use standard flutter material widgets and theme colors/styles. all copy should be defined in intl_en.arb and used with L10n.of(context).copyKey + +// content 1 +// should have a maxWidth, pulled from appconfig +// a. topic input with drop-down of suggestions pulled from TopicListRepo. label text of "topic" and placeholder of some random suggestions from repo +// b. mode input with drop-down of suggestions pulled from ModeListRepo. label text of "mode" and placeholder of some random suggestions from repo +// c. objective input with drop-down of suggestions pulled from LearningObjectiveListRepo. label text of "learning objective" and placeholder of some random suggestions from repo. +// e. dropdown for media type with text "media students should share as part of the activity" +// d. dropdown for selecting "language of activity instructions" which is auto-populated with the user's l1 but can be changed with options coming from pangeaController.pLanguageStore.baseOptions +// f. dropdown for selecting "target language" which is auto-populated with the user's l2 but can be changed with options coming from pangeaController.pLanguageStore.targetOptions +// g. selection for language level +// h. button to generate activities + +// content 2 +// a. app bar with a back button to go back to content 1, and a title of "Activity Planner", and close button to close the activity planner +// b. display of activities generated by the system, arranged in a column. if there is enough horizontal space, the activities should be arranged in a row +// a1. each activity should have a button to "launch activity" which calls a callback. this can be blank for now. +// a2. each activity should have a button to edit the activity. upon edit, the activity should become an input form where the user can freely edit the activity content + +enum _PageMode { + settings, + activities, +} + +class ActivityPlannerPage extends StatefulWidget { + final Room room; + const ActivityPlannerPage({super.key, required this.room}); + + @override + ActivityPlannerPageState createState() => ActivityPlannerPageState(); +} + +class ActivityPlannerPageState extends State { + final _formKey = GlobalKey(); + + /// Index of the content to display + _PageMode _pageMode = _PageMode.settings; + + /// Selected values from the form + String? _selectedTopic; + String? _selectedMode; + String? _selectedObjective; + MediaEnum _selectedMedia = MediaEnum.nan; + String? _selectedLanguageOfInstructions; + String? _selectedTargetLanguage; + int? _selectedCefrLevel; + + /// fetch data from repos + List _topicItems = []; + List _modeItems = []; + List _objectiveItems = []; + + /// List of activities generated by the system + List _activities = []; + + final _topicSearchController = TextEditingController(); + final _objectiveSearchController = TextEditingController(); + + final List _activityControllers = []; + + @override + void initState() { + super.initState(); + _loadDropdownData(); + + _selectedLanguageOfInstructions = + MatrixState.pangeaController.languageController.userL1?.langCode; + _selectedTargetLanguage = + MatrixState.pangeaController.languageController.userL2?.langCode; + _selectedCefrLevel = 0; + + // Initialize controllers for activity editing + for (final activity in _activities) { + _activityControllers.add(TextEditingController(text: activity)); + } + } + + @override + void dispose() { + _topicSearchController.dispose(); + _objectiveSearchController.dispose(); + disposeAndClearActivityControllers(); + super.dispose(); + } + + void disposeAndClearActivityControllers() { + for (final controller in _activityControllers) { + controller.dispose(); + } + _activityControllers.clear(); + } + + ActivitySettingRequestSchema get req => ActivitySettingRequestSchema( + langCode: + MatrixState.pangeaController.languageController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + ); + + Future _loadDropdownData() async { + final topics = await TopicListRepo.get(req); + final modes = await ActivityModeListRepo.get(req); + final objectives = await LearningObjectiveListRepo.get(req); + setState(() { + _topicItems = topics; + _modeItems = modes; + _objectiveItems = objectives; + }); + } + + // send the activity as a message to the room + Future onLaunch(int index) => showFutureLoadingDialog( + context: context, + future: () async { + // this shouldn't often error but just in case since it's not necessary for the activity to be sent + late List tokens; + try { + tokens = await MatrixState.pangeaController.messageData.getTokens( + repEventId: null, + req: TokensRequestModel( + fullText: _activities[index], + langCode: _selectedLanguageOfInstructions!, + senderL1: _selectedLanguageOfInstructions!, + senderL2: _selectedLanguageOfInstructions!, + ), + room: null, + ); + } catch (e) { + debugger(when: kDebugMode); + } + + final eventId = await widget.room.pangeaSendTextEvent( + _activities[index], + messageTag: ModelKey.messageTagActivityPlan, + originalSent: PangeaRepresentation( + langCode: _selectedLanguageOfInstructions!, + text: _activities[index], + originalSent: true, + originalWritten: false, + ), + tokensSent: PangeaMessageTokens(tokens: tokens), + ); + + if (eventId == null) { + debugger(when: kDebugMode); + return; + } + + await widget.room.setPinnedEvents([eventId]); + + Navigator.of(context).pop(); + }, + ); + + Future _generateActivities() async { + if (_formKey.currentState?.validate() ?? false) { + final request = ActivityPlanRequest( + topic: _selectedTopic!, + mode: _selectedMode!, + objective: _selectedObjective!, + media: _selectedMedia, + languageOfInstructions: _selectedLanguageOfInstructions!, + targetLanguage: _selectedTargetLanguage!, + cefrLevel: _selectedCefrLevel!, + ); + + await showFutureLoadingDialog( + context: context, + future: () async { + final response = await ActivityPlanGenerationRepo.get(request); + setState(() { + _activities = response.activityPlans; + disposeAndClearActivityControllers(); + for (final activity in _activities) { + _activityControllers.add(TextEditingController(text: activity)); + } + _pageMode = _PageMode.activities; + }); + }, + ); + } + } + + void _randomizeSelections() { + setState(() { + _selectedTopic = (_topicItems..shuffle()).first.name; + _selectedObjective = (_objectiveItems..shuffle()).first.name; + _selectedMode = (_modeItems..shuffle()).first.name; + }); + } + + // Add validation logic + String? _validateNotNull(String? value) { + if (value == null || value.isEmpty) { + return L10n.of(context).interactiveTranslatorRequired; + } + return null; + } + + @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: Text(l10n.activityPlannerTitle), + ), + body: _pageMode == _PageMode.settings + ? _buildSettingsForm(l10n) + : _buildActivitiesView(l10n), + ); + } + + Widget _buildSettingsForm(L10n l10n) { + return 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, + ), + DropdownButtonFormField2( + hint: Text(l10n.topicPlaceholder), + value: _selectedTopic, + decoration: _selectedTopic != null + ? InputDecoration( + labelText: l10n.topicLabel, + ) + : null, + isExpanded: true, + validator: (value) => _validateNotNull(value), + dropdownSearchData: DropdownSearchData( + searchController: _topicSearchController, + searchInnerWidget: InnerSearchWidget( + searchController: _topicSearchController, + ), + searchInnerWidgetHeight: 60, + searchMatchFn: (item, searchValue) { + return item.value + .toString() + .toLowerCase() + .contains(searchValue.toLowerCase()); + }, + ), + items: _topicItems + .map( + (e) => DropdownMenuItem( + value: e.name, + child: Text(e.name), + ), + ) + .toList(), + onChanged: (value) { + _selectedTopic = value; + }, + dropdownStyleData: const DropdownStyleData(maxHeight: 400), + ), + const SizedBox(height: 24), + DropdownButtonFormField2( + hint: Text(l10n.learningObjectivePlaceholder), + decoration: _selectedObjective != null + ? InputDecoration(labelText: l10n.learningObjectiveLabel) + : null, + validator: (value) => _validateNotNull(value), + items: _objectiveItems + .map( + (e) => + DropdownMenuItem(value: e.name, child: Text(e.name)), + ) + .toList(), + onChanged: (val) => _selectedObjective = val, + dropdownStyleData: const DropdownStyleData(maxHeight: 400), + value: _selectedObjective, + dropdownSearchData: DropdownSearchData( + searchController: _objectiveSearchController, + searchInnerWidget: InnerSearchWidget( + searchController: _objectiveSearchController, + ), + searchInnerWidgetHeight: 60, + searchMatchFn: (item, searchValue) { + return item.value + .toString() + .toLowerCase() + .contains(searchValue.toLowerCase()); + }, + ), + ), + const SizedBox(height: 24), + DropdownButtonFormField2( + decoration: _selectedMode != null + ? InputDecoration(labelText: l10n.modeLabel) + : null, + hint: Text(l10n.modePlaceholder), + validator: (value) => _validateNotNull(value), + items: _modeItems + .map( + (e) => + DropdownMenuItem(value: e.name, child: Text(e.name)), + ) + .toList(), + onChanged: (val) => _selectedMode = val, + value: _selectedMode, + ), + const SizedBox(height: 24), + DropdownButtonFormField2( + decoration: InputDecoration(labelText: l10n.mediaLabel), + items: MediaEnum.values + .map( + (e) => DropdownMenuItem( + value: e, + child: Text(e.toDisplayCopyUsingL10n(context)), + ), + ) + .toList(), + onChanged: (val) => _selectedMedia = val ?? MediaEnum.nan, + value: _selectedMedia, + ), + const SizedBox(height: 24), + LanguageLevelDropdown( + initialLevel: 0, + onChanged: (val) => _selectedCefrLevel = val, + ), + const SizedBox(height: 24), + PLanguageDropdown( + languages: + MatrixState.pangeaController.pLanguageStore.baseOptions, + onChange: (val) => _selectedTargetLanguage = val.langCode, + initialLanguage: + MatrixState.pangeaController.languageController.userL1, + isL2List: false, + decorationText: L10n.of(context).languageOfInstructionsLabel, + ), + const SizedBox(height: 24), + PLanguageDropdown( + languages: + MatrixState.pangeaController.pLanguageStore.targetOptions, + onChange: (val) => _selectedTargetLanguage = val.langCode, + initialLanguage: + MatrixState.pangeaController.languageController.userL2, + decorationText: L10n.of(context).targetLanguageLabel, + isL2List: true, + ), + const SizedBox(height: 24), + Row( + children: [ + IconButton( + icon: const Icon(Icons.shuffle), + onPressed: _randomizeSelections, + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _generateActivities, + child: Text(l10n.generateActivitiesButton), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildActivitiesView(L10n l10n) { + return ListView( + padding: const EdgeInsets.all(16), + children: _activities.asMap().entries.map((entry) { + final index = entry.key; + return ActivityPlanTile( + activity: _activities[index], + onLaunch: () => onLaunch(index), + onEdit: (val) { + setState(() { + _activities[index] = val; + }); + }, + ); + }).toList(), + ); + } +} + +class InnerSearchWidget extends StatelessWidget { + const InnerSearchWidget({ + super.key, + required TextEditingController searchController, + }) : _objectiveSearchController = searchController; + + final TextEditingController _objectiveSearchController; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + top: 8, + bottom: 4, + right: 8, + left: 8, + ), + child: TextFormField( + controller: _objectiveSearchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + hintText: L10n.of(context).search, + icon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/activity_planner/learning_objective_list_repo.dart b/lib/pangea/activity_planner/learning_objective_list_repo.dart new file mode 100644 index 000000000..cc08beabe --- /dev/null +++ b/lib/pangea/activity_planner/learning_objective_list_repo.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../common/network/requests.dart'; + +class LearningObjectiveListRepo { + static final GetStorage _objectiveListStorage = + GetStorage('objective_list_storage'); + + static void set( + ActivitySettingRequestSchema request, + List response, + ) { + _objectiveListStorage.write( + request.storageKey, + response.map((e) => e.toJson()).toList(), + ); + } + + static List fromJson(Iterable json) { + return List.from( + json.map((x) => ActivitySettingResponseSchema.fromJson(x)), + ); + } + + static Future> get( + ActivitySettingRequestSchema request, + ) async { + final cachedJson = _objectiveListStorage.read(request.storageKey); + if (cachedJson != null) { + return LearningObjectiveListRepo.fromJson(cachedJson); + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.objectiveList, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = LearningObjectiveListRepo.fromJson(decodedBody); + + set(request, response); + + return response; + } +} diff --git a/lib/pangea/activity_planner/list_request_schema.dart b/lib/pangea/activity_planner/list_request_schema.dart new file mode 100644 index 000000000..d3034820b --- /dev/null +++ b/lib/pangea/activity_planner/list_request_schema.dart @@ -0,0 +1,37 @@ +class ActivitySettingRequestSchema { + final String langCode; + + ActivitySettingRequestSchema({required this.langCode}); + + Map toJson() { + return { + 'lang_code': langCode, + }; + } + + String get storageKey => 'topic_list-$langCode'; +} + +class ActivitySettingResponseSchema { + final String defaultName; + final String name; + + ActivitySettingResponseSchema({ + required this.defaultName, + required this.name, + }); + + factory ActivitySettingResponseSchema.fromJson(Map json) { + return ActivitySettingResponseSchema( + defaultName: json['default_name'], + name: json['name'], + ); + } + + Map toJson() { + return { + 'default_name': defaultName, + 'name': name, + }; + } +} diff --git a/lib/pangea/activity_planner/media_enum.dart b/lib/pangea/activity_planner/media_enum.dart new file mode 100644 index 000000000..95e029235 --- /dev/null +++ b/lib/pangea/activity_planner/media_enum.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +enum MediaEnum { + images, + videos, + voiceMessages, + nan, +} + +extension MediaEnumExtension on MediaEnum { + //fromString + static MediaEnum fromString(String value) { + switch (value) { + case 'images': + return MediaEnum.images; + case 'videos': + return MediaEnum.videos; + case 'voice_messages': + return MediaEnum.voiceMessages; + case 'nan': + return MediaEnum.nan; + default: + return MediaEnum.nan; + } + } + + String get string { + switch (this) { + case MediaEnum.images: + return 'images'; + case MediaEnum.videos: + return 'videos'; + case MediaEnum.voiceMessages: + return 'voice_messages'; + case MediaEnum.nan: + return 'nan'; + } + } + + //toDisplayCopyUsingL10n + String toDisplayCopyUsingL10n(BuildContext context) { + switch (this) { + case MediaEnum.images: + return L10n.of(context).image; + case MediaEnum.videos: + return L10n.of(context).video; + case MediaEnum.voiceMessages: + return L10n.of(context).voiceMessage; + case MediaEnum.nan: + return L10n.of(context).nan; + } + } +} diff --git a/lib/pangea/activity_planner/topic_list_repo.dart b/lib/pangea/activity_planner/topic_list_repo.dart new file mode 100644 index 000000000..299737403 --- /dev/null +++ b/lib/pangea/activity_planner/topic_list_repo.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/activity_planner/list_request_schema.dart'; +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../common/network/requests.dart'; + +class TopicListRepo { + static final GetStorage _topicListStorage = GetStorage('topic_list_storage'); + + static void set( + ActivitySettingRequestSchema request, + List response, + ) { + _topicListStorage.write( + request.storageKey, + response.map((e) => e.toJson()).toList(), + ); + } + + static List fromJson(Iterable json) { + return List.from( + json.map((x) => ActivitySettingResponseSchema.fromJson(x)), + ); + } + + static Future> get( + ActivitySettingRequestSchema request, + ) async { + final cachedJson = _topicListStorage.read(request.storageKey); + if (cachedJson != null) { + return TopicListRepo.fromJson(cachedJson); + } + + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.topicList, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = TopicListRepo.fromJson(decodedBody); + + set(request, response); + + return response; + } +} diff --git a/lib/pangea/chat_settings/widgets/language_level_dropdown.dart b/lib/pangea/chat_settings/widgets/language_level_dropdown.dart index 74e8bea4c..3b30303ea 100644 --- a/lib/pangea/chat_settings/widgets/language_level_dropdown.dart +++ b/lib/pangea/chat_settings/widgets/language_level_dropdown.dart @@ -23,6 +23,7 @@ class LanguageLevelDropdown extends StatelessWidget { @override Widget build(BuildContext context) { return DropdownButtonFormField2( + decoration: InputDecoration(labelText: L10n.of(context).cefrLevelLabel), hint: Text( L10n.of(context).selectLanguageLevel, overflow: TextOverflow.clip, diff --git a/lib/pangea/common/constants/model_keys.dart b/lib/pangea/common/constants/model_keys.dart index 5329a3187..041666b35 100644 --- a/lib/pangea/common/constants/model_keys.dart +++ b/lib/pangea/common/constants/model_keys.dart @@ -87,8 +87,11 @@ class ModelKey { /// This will help us know to omit the message from notifications, /// bot responses, etc. It will also help use find the message if /// we want to gather user edits for LLM fine-tuning. + /// @ggurdin: Maybe this not the way to do this and we should be using + /// something built in to matrix? should talk about this static const String messageTags = "p.tag"; static const String messageTagMorphEdit = "morph_edit"; + static const String messageTagActivityPlan = "activity_plan"; static const String baseDefinition = "base_definition"; static const String targetDefinition = "target_definition"; diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index 61382524d..b28b163da 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -66,6 +66,12 @@ class PApiUrls { static String lemmaDictionary = "${PApiUrls.choreoEndpoint}/lemma_definition"; + static String activityPlanGeneration = + "${PApiUrls.choreoEndpoint}/activity_plan"; + static String activityModeList = "${PApiUrls.choreoEndpoint}/modes"; + static String objectiveList = "${PApiUrls.choreoEndpoint}/objectives"; + static String topicList = "${PApiUrls.choreoEndpoint}/topics"; + ///-------------------------------- revenue cat -------------------------- static String rcAppsChoreo = "${PApiUrls.subscriptionEndpoint}/app_ids"; static String rcProductsChoreo = diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index 012f87a3f..8c1e9108c 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -21,6 +21,7 @@ enum InstructionsEnum { clickBestOption, unlockedLanguageTools, lemmaMeaning, + activityPlannerOverview, } extension InstructionsEnumExtension on InstructionsEnum { @@ -36,6 +37,7 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.tooltipInstructionsTitle; case InstructionsEnum.missingVoice: return l10n.missingVoiceTitle; + case InstructionsEnum.activityPlannerOverview: case InstructionsEnum.clickAgainToDeselect: case InstructionsEnum.speechToText: case InstructionsEnum.l1Translation: @@ -83,6 +85,8 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.unlockedLanguageTools; case InstructionsEnum.lemmaMeaning: return l10n.lemmaMeaningInstructionsBody; + case InstructionsEnum.activityPlannerOverview: + return l10n.activityPlannerOverviewInstructionsBody; } } diff --git a/lib/pangea/learning_settings/widgets/p_language_dialog.dart b/lib/pangea/learning_settings/widgets/p_language_dialog.dart index 47e3d947c..9061a8d8a 100644 --- a/lib/pangea/learning_settings/widgets/p_language_dialog.dart +++ b/lib/pangea/learning_settings/widgets/p_language_dialog.dart @@ -66,6 +66,7 @@ Future pLanguageDialog( selectedSourceLanguage ?? LanguageModel.unknown, languages: pangeaController.pLanguageStore.baseOptions, isL2List: false, + decorationText: L10n.of(context).myBaseLanguage, ), PQuestionContainer( title: L10n.of(context).whatLanguageYouWantToLearn, @@ -76,6 +77,7 @@ Future pLanguageDialog( initialLanguage: selectedTargetLanguage, languages: pangeaController.pLanguageStore.targetOptions, isL2List: true, + decorationText: L10n.of(context).iWantToLearn, ), ], ), diff --git a/lib/pangea/learning_settings/widgets/p_language_dropdown.dart b/lib/pangea/learning_settings/widgets/p_language_dropdown.dart index 93c80d7e3..d21c40232 100644 --- a/lib/pangea/learning_settings/widgets/p_language_dropdown.dart +++ b/lib/pangea/learning_settings/widgets/p_language_dropdown.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/learning_settings/enums/l2_support_enum.dart'; @@ -15,6 +15,7 @@ class PLanguageDropdown extends StatefulWidget { final Function(LanguageModel) onChange; final bool showMultilingual; final bool isL2List; + final String decorationText; final String? error; const PLanguageDropdown({ @@ -23,7 +24,8 @@ class PLanguageDropdown extends StatefulWidget { required this.onChange, required this.initialLanguage, this.showMultilingual = false, - required this.isL2List, + required this.decorationText, + this.isL2List = false, this.error, }); @@ -63,52 +65,30 @@ class _PLanguageDropdownState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - width: 1, - ), - borderRadius: const BorderRadius.all(Radius.circular(36)), - ), - padding: const EdgeInsets.symmetric(horizontal: 24), - child: DropdownButton( - hint: Row( - children: [ - const Icon(Icons.language_outlined), - const SizedBox(width: 10), - Text( - widget.isL2List - ? L10n.of(context).iWantToLearn - : L10n.of(context).myBaseLanguage, - ), - ], - ), - isExpanded: true, - icon: const Icon(Icons.keyboard_arrow_down), - underline: Container(), - items: [ - if (widget.showMultilingual) - DropdownMenuItem( - value: LanguageModel.multiLingual(context), - child: LanguageDropDownEntry( - languageModel: LanguageModel.multiLingual(context), - isL2List: widget.isL2List, - ), - ), - ...sortedLanguages.map( - (languageModel) => DropdownMenuItem( - value: languageModel, - child: LanguageDropDownEntry( - languageModel: languageModel, - isL2List: widget.isL2List, - ), + DropdownButtonFormField2( + decoration: InputDecoration(labelText: widget.decorationText), + isExpanded: true, + items: [ + if (widget.showMultilingual) + DropdownMenuItem( + value: LanguageModel.multiLingual(context), + child: LanguageDropDownEntry( + languageModel: LanguageModel.multiLingual(context), + isL2List: widget.isL2List, ), ), - ], - onChanged: (value) => widget.onChange(value!), - value: widget.initialLanguage, - ), + ...sortedLanguages.map( + (languageModel) => DropdownMenuItem( + value: languageModel, + child: LanguageDropDownEntry( + languageModel: languageModel, + isL2List: widget.isL2List, + ), + ), + ), + ], + onChanged: (value) => widget.onChange(value!), + value: widget.initialLanguage, ), AnimatedSize( duration: FluffyThemes.animationDuration, diff --git a/lib/pangea/login/pages/user_settings_view.dart b/lib/pangea/login/pages/user_settings_view.dart index ba3441066..279d5f751 100644 --- a/lib/pangea/login/pages/user_settings_view.dart +++ b/lib/pangea/login/pages/user_settings_view.dart @@ -94,6 +94,7 @@ class UserSettingsView extends StatelessWidget { initialLanguage: controller.selectedTargetLanguage, isL2List: true, error: controller.selectedLanguageError, + decorationText: L10n.of(context).iWantToLearn, ), ), if (controller.isSSOSignup) diff --git a/lib/pangea/user/pages/find_partner_view.dart b/lib/pangea/user/pages/find_partner_view.dart index 10963060f..5ba019bbe 100644 --- a/lib/pangea/user/pages/find_partner_view.dart +++ b/lib/pangea/user/pages/find_partner_view.dart @@ -239,6 +239,9 @@ class LanguageSelectionRow extends StatelessWidget { initialLanguage: isSource ? controller.sourceLanguageSearch : controller.targetLanguageSearch, + decorationText: isSource + ? L10n.of(context).myBaseLanguage + : L10n.of(context).iWantToLearn, ), ), ], diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 8e02df288..b3c176141 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -6,4 +6,8 @@ class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } }