You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/activity_planner/activity_planner_page.dart

482 lines
18 KiB
Dart

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 String roomID;
const ActivityPlannerPage({super.key, required this.roomID});
@override
ActivityPlannerPageState createState() => ActivityPlannerPageState();
}
class ActivityPlannerPageState extends State<ActivityPlannerPage> {
final _formKey = GlobalKey<FormState>();
/// 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<ActivitySettingResponseSchema> _topicItems = [];
List<ActivitySettingResponseSchema> _modeItems = [];
List<ActivitySettingResponseSchema> _objectiveItems = [];
/// List of activities generated by the system
List<String> _activities = [];
final _topicSearchController = TextEditingController();
final _objectiveSearchController = TextEditingController();
final List<TextEditingController> _activityControllers = [];
Room? get room => Matrix.of(context).client.getRoomById(widget.roomID);
@override
void initState() {
super.initState();
if (room == null) {
Navigator.of(context).pop();
return;
}
_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<void> _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<void> 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
List<PangeaToken>? 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 room?.pangeaSendTextEvent(
_activities[index],
messageTag: ModelKey.messageTagActivityPlan,
originalSent: PangeaRepresentation(
langCode: _selectedLanguageOfInstructions!,
text: _activities[index],
originalSent: true,
originalWritten: false,
),
tokensSent:
tokens != null ? PangeaMessageTokens(tokens: tokens) : null,
);
if (eventId == null) {
debugger(when: kDebugMode);
return;
}
await room?.setPinnedEvents([eventId]);
Navigator.of(context).pop();
},
);
Future<void> _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;
});
},
);
}
}
bool get _canRandomizeSelections =>
_topicItems.isNotEmpty &&
_objectiveItems.isNotEmpty &&
_modeItems.isNotEmpty;
void _randomizeSelections() {
if (!_canRandomizeSelections) return;
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<String>(
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<String>(
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<String>(
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<MediaEnum>(
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:
_canRandomizeSelections ? _randomizeSelections : null,
),
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),
),
),
),
);
}
}