diff --git a/LearningLens2025/.gitignore b/LearningLens2025/.gitignore
index a7393430..235f9de6 100644
--- a/LearningLens2025/.gitignore
+++ b/LearningLens2025/.gitignore
@@ -49,8 +49,12 @@ terraform/terraformout.txt
# npm
/lambda/code_eval/node_modules
-/lambda/gettoken/node_modules
-/lambda/gettoken/gettoken.zip
+/lambda/ai_log/node_modules
+/lambda/game_data/node_modules
+/lambda/reflections/node_modules
+/lambda/ai_log/ai_log.zip
/lambda/code_eval/code_eval.zip
+/lambda/game_data/game_data.zip
+/lambda/reflections/reflections.zip
# Any extra javascript files for lambda functions
!lambda/code_eval/*.js
diff --git a/LearningLens2025/frontend/.example.env b/LearningLens2025/frontend/.example.env
index 708c6292..b1344b3e 100644
--- a/LearningLens2025/frontend/.example.env
+++ b/LearningLens2025/frontend/.example.env
@@ -13,4 +13,6 @@ deepseek_apiKey=
GOOGLE_CLIENT_ID=
AI_LOGGING_URL=
CODE_EVAL_URL=
+GAME_URL=
+REFLECTIONS_URL=
LOCAL_MODEL_DOWNLOAD_URL_PATH=
diff --git a/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_function_caller_view.dart b/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_function_caller_view.dart
index bca7cc40..837da7ae 100644
--- a/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_function_caller_view.dart
+++ b/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_function_caller_view.dart
@@ -10,6 +10,8 @@ import 'package:learninglens_app/Api/llm/perplexity_api.dart';
import 'package:learninglens_app/Api/lms/factory/lms_factory.dart';
import 'package:learninglens_app/Controller/custom_appbar.dart';
import 'package:learninglens_app/services/local_storage_service.dart';
+import 'package:learninglens_app/Api/llm/local_llm_service.dart'; // local llm
+import 'package:flutter/foundation.dart';
class TextBasedFunctionCallerView extends StatefulWidget {
const TextBasedFunctionCallerView({super.key});
@@ -30,6 +32,8 @@ class _TextBasedFunctionCallerViewState
LlmType selectedLLM = LlmType.GROK; //default to chatgpt
late LLM llm;
+ bool _localLlmAvail = !kIsWeb;
+
@override
void initState() {
super.initState();
@@ -54,6 +58,8 @@ class _TextBasedFunctionCallerViewState
aiModel = PerplexityLLM(LocalStorageService.getPerplexityKey());
} else if (selectedLLM == LlmType.DEEPSEEK) {
aiModel = DeepseekLLM(LocalStorageService.getDeepseekKey());
+ } else if (selectedLLM == LlmType.LOCAL) {
+ aiModel = LocalLLMService();
} else {
// default
aiModel = OpenAiLLM(LocalStorageService.getOpenAIKey());
@@ -199,11 +205,18 @@ class _TextBasedFunctionCallerViewState
items: LlmType.values.map((LlmType llm) {
return DropdownMenuItem(
value: llm,
- enabled: LocalStorageService.userHasLlmKey(llm),
+ enabled: (llm == LlmType.LOCAL &&
+ LocalStorageService.getLocalLLMPath() != "" &&
+ _localLlmAvail) ||
+ LocalStorageService.userHasLlmKey(llm),
child: Text(
llm.displayName,
style: TextStyle(
- color: LocalStorageService.userHasLlmKey(llm)
+ color: (llm == LlmType.LOCAL &&
+ LocalStorageService.getLocalLLMPath() !=
+ "" &&
+ _localLlmAvail) ||
+ LocalStorageService.userHasLlmKey(llm)
? Colors.black87
: Colors.grey,
),
diff --git a/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_llm_client.dart b/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_llm_client.dart
index d68c5d22..d7c67b36 100644
--- a/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_llm_client.dart
+++ b/LearningLens2025/frontend/lib/Api/experimental/assistant/textbased_llm_client.dart
@@ -3,6 +3,7 @@ import 'package:learninglens_app/Api/experimental/assistant/textbased_function_c
import 'package:learninglens_app/Api/llm/llm_api_modules_base.dart';
import 'package:learninglens_app/Api/llm/prompt_engine.dart';
import 'package:learninglens_app/services/api_service.dart';
+import 'package:learninglens_app/Api/llm/local_llm_service.dart';
// Replicate the functionality used in chatgpt_client, but swap over to prompt engineering instead of the function caller.
// This code gears the development for the assistant to be more generic in terms of which llm is used rather than relying on
@@ -61,34 +62,45 @@ class TextBasedLLMClient {
}
}
- /// Calls the OpenAI Chat Completion API with the entire conversation so far
+ /// Calls the OpenAI Chat Completion API with the entire conversation so far or call locl LLM.
Future _callLLM(List
');
+ if (!RegExp(r'?(p|div|ul|ol|h[1-6])\b', caseSensitive: false)
+ .hasMatch(h)) {
+ h = '
$h
';
+ }
+ h = h.replaceAll('\n', '
');
+ return h;
+ }
+
+ if (reader.canProvide(Formats.htmlText)) {
+ var html = await reader.readValue(Formats.htmlText);
+ if (html != null && html.trim().isNotEmpty) {
+ html = normalizeLineBreaks(html);
+ final delta = qh.HtmlToDelta().convert(html);
+
+ final sel = controller.selection;
+ final base = sel.baseOffset;
+ final extent = sel.extentOffset;
+ final deleteLen = (extent - base).abs();
+
+ controller.replaceText(
+ base,
+ deleteLen,
+ '',
+ TextSelection.collapsed(offset: base),
+ );
+ controller.compose(
+ delta, controller.selection, quill.ChangeSource.local);
+ controller.updateSelection(
+ TextSelection.collapsed(offset: base + delta.length),
+ quill.ChangeSource.local,
+ );
+ return;
+ }
+ }
+
+ // fallback to normal paste (plain text)
+ Actions.invoke(
+ editorCtx, const PasteTextIntent(SelectionChangedCause.keyboard));
+ }
+
+ Widget _richPasteWrapper({
+ required quill.QuillController controller,
+ required Widget child,
+ void Function(BuildContext ctx)? onInnerContext,
+ }) {
+ late BuildContext editorCtx;
+
+ return Shortcuts(
+ shortcuts: {
+ LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyV):
+ const PasteTextIntent(SelectionChangedCause.keyboard),
+ LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.keyV):
+ const PasteTextIntent(SelectionChangedCause.keyboard),
+ },
+ child: Actions(
+ actions: {
+ PasteTextIntent: CallbackAction(
+ onInvoke: (_) => _richPaste(editorCtx, controller),
+ ),
+ },
+ child: Builder(
+ builder: (inner) {
+ editorCtx = inner;
+ onInnerContext?.call(editorCtx);
+ return child;
+ },
+ ),
+ ),
+ );
+ }
+
/// Open the Quill editor dialog for a given essay (or "general draft" if null).
/// Saves the draft (Quill delta JSON) locally in `_drafts` and via stubbed backend.
void _openQuillEditorDialogFor(Assignment? essay) async {
if (!_sessionActive) return;
+ BuildContext? draftEditorCtx;
+
+ Widget editorWithContextMenuForDraft() {
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onSecondaryTapDown: (details) async {
+ // Show a desktop-style context menu with Paste (rich)
+ final selected = await showMenu(
+ context: context,
+ position: RelativeRect.fromLTRB(
+ details.globalPosition.dx,
+ details.globalPosition.dy,
+ details.globalPosition.dx,
+ details.globalPosition.dy,
+ ),
+ items: const [
+ PopupMenuItem(value: 'paste_rich', child: Text('Paste (rich)')),
+ ],
+ );
+ if (selected == 'paste_rich' && draftEditorCtx != null) {
+ _richPaste(draftEditorCtx!, _quillDraftController);
+ }
+ },
+ child: quill.QuillEditor(
+ focusNode: _draftFocus,
+ scrollController: _quillDraftScrollController,
+ controller: _quillDraftController,
+ config: const quill.QuillEditorConfig(
+ padding: EdgeInsets.fromLTRB(24, 20, 24, 28),
+ placeholder: 'Write your essay here…',
+ ),
+ ),
+ );
+ }
+
// Load current session draft
if (_currentSession!.draftDeltaOps != null) {
_quillDraftController.document =
@@ -2078,13 +2244,28 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
// ----- Toolbar (simple) -----
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
- child: quill.QuillSimpleToolbar(
- controller: _quillDraftController,
- config: const quill.QuillSimpleToolbarConfig(
- multiRowsDisplay: true,
- showDividers: true,
- showClipboardPaste: true,
- ),
+ child: Row(
+ children: [
+ IconButton(
+ tooltip: 'Paste (rich)',
+ icon: const Icon(Icons.content_paste),
+ onPressed: () {
+ final ctx = draftEditorCtx ?? context;
+ _richPaste(ctx, _quillDraftController);
+ },
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: quill.QuillSimpleToolbar(
+ controller: _quillDraftController,
+ config: const quill.QuillSimpleToolbarConfig(
+ multiRowsDisplay: true,
+ showDividers: true,
+ showClipboardPaste: false,
+ ),
+ ),
+ ),
+ ],
),
),
const Divider(height: 1),
@@ -2105,15 +2286,10 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
),
],
),
- child: quill.QuillEditor(
- focusNode: _draftFocus,
- scrollController: _quillDraftScrollController,
+ child: _richPasteWrapper(
controller: _quillDraftController,
- config: const quill.QuillEditorConfig(
- // feels like page margins
- padding: EdgeInsets.fromLTRB(24, 20, 24, 28),
- placeholder: 'Write your essay here…',
- ),
+ onInnerContext: (ctx) => draftEditorCtx = ctx,
+ child: editorWithContextMenuForDraft(),
),
),
),
@@ -2222,6 +2398,42 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
void _openNotesEditorDialogFor(Assignment? essay) async {
if (!_sessionActive) return;
+ BuildContext? notesEditorCtx;
+
+ Widget editorWithContextMenuForNotes() {
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onSecondaryTapDown: (details) async {
+ // Show a desktop-style context menu with Paste (rich)
+ final selected = await showMenu(
+ context: context,
+ position: RelativeRect.fromLTRB(
+ details.globalPosition.dx,
+ details.globalPosition.dy,
+ details.globalPosition.dx,
+ details.globalPosition.dy,
+ ),
+ items: const [
+ PopupMenuItem(value: 'paste_rich', child: Text('Paste (rich)')),
+ ],
+ );
+ // ignore: unnecessary_null_comparison
+ if (selected == 'paste_rich' && notesEditorCtx != null) {
+ _richPaste(notesEditorCtx!, _quillNotesController);
+ }
+ },
+ child: quill.QuillEditor(
+ focusNode: _notesFocus,
+ scrollController: _quillNotesScrollController,
+ controller: _quillNotesController,
+ config: const quill.QuillEditorConfig(
+ padding: EdgeInsets.fromLTRB(24, 20, 24, 28),
+ placeholder: 'Take your notes here…',
+ ),
+ ),
+ );
+ }
+
if (_currentSession!.notesDeltaOps != null) {
_quillNotesController.document =
quill.Document.fromJson(_currentSession!.notesDeltaOps!);
@@ -2269,13 +2481,28 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
// Toolbar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
- child: quill.QuillSimpleToolbar(
- controller: _quillNotesController,
- config: const quill.QuillSimpleToolbarConfig(
- multiRowsDisplay: true,
- showDividers: true,
- showClipboardPaste: true,
- ),
+ child: Row(
+ children: [
+ IconButton(
+ tooltip: 'Paste (rich)',
+ icon: const Icon(Icons.content_paste),
+ onPressed: () {
+ final ctx = notesEditorCtx ?? context;
+ _richPaste(ctx, _quillNotesController);
+ },
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: quill.QuillSimpleToolbar(
+ controller: _quillNotesController,
+ config: const quill.QuillSimpleToolbarConfig(
+ multiRowsDisplay: true,
+ showDividers: true,
+ showClipboardPaste: false,
+ ),
+ ),
+ ),
+ ],
),
),
const Divider(height: 1),
@@ -2285,31 +2512,24 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
child: Padding(
padding: const EdgeInsets.all(8),
child: DecoratedBox(
- decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surface,
- borderRadius: BorderRadius.circular(12),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.05),
- blurRadius: 6,
- offset: const Offset(0, 2),
- ),
- ],
- ),
- child: quill.QuillEditor(
- focusNode: _notesFocus,
- scrollController: _quillNotesScrollController,
- controller: _quillNotesController,
- config: const quill.QuillEditorConfig(
- // feels like page margins
- padding: EdgeInsets.fromLTRB(24, 20, 24, 28),
- placeholder: 'Write your notes here…',
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.05),
+ blurRadius: 6,
+ offset: const Offset(0, 2),
+ ),
+ ],
),
- ),
- ),
+ child: _richPasteWrapper(
+ controller: _quillNotesController,
+ onInnerContext: (ctx) => notesEditorCtx = ctx,
+ child: editorWithContextMenuForNotes(),
+ )),
),
),
-
// Footer
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
@@ -2365,6 +2585,29 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid
* - _LabeledRow: neat label/value row used in the dialog body
* ──────────────────────────────────────────────────────────────────────────── */
+class _AssistantMessageCard extends StatelessWidget {
+ const _AssistantMessageCard({
+ required this.text,
+ required this.child,
+ });
+ final String text;
+ final Widget child;
+ @override
+ Widget build(BuildContext context) {
+ return Stack(children: [
+ child,
+ Positioned(
+ right: 8,
+ top: 8,
+ child: IconButton(
+ tooltip: 'Copy to clipboard',
+ icon: const Icon(Icons.copy, size: 18),
+ onPressed: () => _copyMarkdown(context, text),
+ ))
+ ]);
+ }
+}
+
/// Essay details modal content (title, due, description, actions).
class _EssayModalContent extends StatelessWidget {
const _EssayModalContent({
diff --git a/LearningLens2025/frontend/lib/Views/essay_generation.dart b/LearningLens2025/frontend/lib/Views/essay_generation.dart
index 612dd7f2..1cd8a821 100644
--- a/LearningLens2025/frontend/lib/Views/essay_generation.dart
+++ b/LearningLens2025/frontend/lib/Views/essay_generation.dart
@@ -292,7 +292,7 @@ class _EssayGenerationState extends State {
if (selectedLLM == LlmType.LOCAL) ...[
const SizedBox(height: 6),
const Text(
- "The recommended model for Rubric Generation is a 7B or higher reasoning (Qwen) model.\nRunning a Large Language Model (LLM) requires substantial hardware resources. Smaller models may produce inaccurate or misleading responses.\nFor optimal results, we recommend using the external API.\n",
+ "The recommended model for Rubric Generation is a 7B or higher reasoning (Qwen) model.\nRunning a Large Language Model (LLM) locally typically requires substantial hardware resources. Using smaller models may produce inaccurate or misleading responses.\nFor best results, we recommend using the external LLM.\nPlease use the local LLM responsibly and independently verify any critical information.",
style: TextStyle(fontSize: 13, color: Colors.black54),
),
],
diff --git a/LearningLens2025/frontend/lib/Views/g_assignment_create.dart b/LearningLens2025/frontend/lib/Views/g_assignment_create.dart
index e76694a3..342476b3 100644
--- a/LearningLens2025/frontend/lib/Views/g_assignment_create.dart
+++ b/LearningLens2025/frontend/lib/Views/g_assignment_create.dart
@@ -4,14 +4,11 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:learninglens_app/Api/lms/factory/lms_factory.dart';
-import 'package:learninglens_app/Api/lms/google_classroom/google_classroom_api.dart';
import 'package:learninglens_app/Api/lms/google_classroom/google_lms_service.dart';
import 'package:learninglens_app/Controller/custom_appbar.dart';
import 'package:learninglens_app/services/local_storage_service.dart';
class CreateAssignmentPage extends StatefulWidget {
- final GoogleClassroomApi _googleClassroomApi = GoogleClassroomApi();
-
@override
_CreateAssignmentPageState createState() => _CreateAssignmentPageState();
}
@@ -22,7 +19,7 @@ class _CreateAssignmentPageState extends State {
int? _points;
DateTime? _dueDate;
TimeOfDay? _dueTime;
- final String _topic = 'Essay'; // Made static with fixed value
+ final String _topic = 'essay'; // Made static with fixed value
String? _title;
String? _instructions;
List _courses = [];
@@ -110,71 +107,30 @@ class _CreateAssignmentPageState extends State {
return;
}
- final url = Uri.parse(
- 'https://classroom.googleapis.com/v1/courses/$_selectedCourseId/courseWork');
- final headers = {
- 'Authorization': 'Bearer $token',
- 'Content-Type': 'application/json',
- };
-
- Map requestBody = {
- 'title': _title,
- 'description': _instructions,
- 'state': 'PUBLISHED',
- 'workType': 'ASSIGNMENT',
- 'maxPoints': _points,
- };
-
- if (_dueDate != null && _dueTime != null) {
- requestBody['dueDate'] = {
- 'year': _dueDate!.year,
- 'month': _dueDate!.month,
- 'day': _dueDate!.day,
- };
- requestBody['dueTime'] = {
- 'hours': _dueTime!.hour,
- 'minutes': _dueTime!.minute,
- 'seconds': 0,
- };
- }
-
- String? topicIdNew = await widget._googleClassroomApi
- .getTopicId(_selectedCourseId!, _topic);
- if (topicIdNew != null) {
- requestBody['topicId'] = topicIdNew;
- }
-
- final body = jsonEncode(requestBody);
-
- try {
- final response = await http.post(url, headers: headers, body: body);
-
- if (response.statusCode == 200) {
- print('Assignment created successfully!');
- await GoogleLmsService()
- .courses
- ?.firstWhere((c) => c.id.toString() == _selectedCourseId)
- .refreshQuizzes();
- Navigator.pop(context);
- } else {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content:
- Text('Error creating assignment: ${response.statusCode}'),
- backgroundColor: Colors.red,
- ),
- );
- }
- } catch (e) {
+ var ess = await GoogleLmsService().createAssignment(
+ _selectedCourseId!,
+ "",
+ _title!,
+ "",
+ _dueDate?.millisecondsSinceEpoch.toString() ?? "",
+ _points?.toString() ?? "",
+ _instructions ?? "");
+ if (ess != null) {
+ print('Assignment created successfully!');
+ await GoogleLmsService()
+ .courses
+ ?.firstWhere((c) => c.id.toString() == _selectedCourseId)
+ .refreshEssays();
+ Navigator.pop(context);
+ } else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
- content: Text('Network error: ${e.toString()}'),
+ content: Text('Error creating assignment.'),
backgroundColor: Colors.red,
),
);
- } finally {
- setState(() => _isSubmitting = false);
}
+ setState(() => _isSubmitting = false);
}
}
diff --git a/LearningLens2025/frontend/lib/Views/gamification_view.dart b/LearningLens2025/frontend/lib/Views/gamification_view.dart
index 1418ad05..2b4dc803 100644
--- a/LearningLens2025/frontend/lib/Views/gamification_view.dart
+++ b/LearningLens2025/frontend/lib/Views/gamification_view.dart
@@ -1,6 +1,8 @@
import 'dart:convert';
-import 'dart:io';
-
+import 'package:learninglens_app/beans/course.dart';
+import 'package:learninglens_app/beans/participant.dart';
+import 'package:intl/intl.dart';
+import 'package:learninglens_app/Api/lms/factory/lms_factory.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -11,12 +13,15 @@ import 'package:learninglens_app/Api/llm/llm_api_modules_base.dart';
import 'package:learninglens_app/Api/llm/openai_api.dart';
import 'package:learninglens_app/Api/llm/perplexity_api.dart';
import 'package:learninglens_app/Api/lms/lms_interface.dart';
+import 'package:learninglens_app/services/gamification_service.dart';
import 'package:learninglens_app/services/local_storage_service.dart';
import 'package:learninglens_app/Games/quiz_game.dart';
import 'package:learninglens_app/Games/matching_game.dart';
import 'package:learninglens_app/Games/flashcard_game.dart';
+import 'package:learninglens_app/Games/game_result.dart';
import 'package:learninglens_app/services/ai_file_service.dart';
import 'package:learninglens_app/Api/llm/enum/llm_enum.dart';
+import 'package:learninglens_app/Api/llm/local_llm_service.dart'; // local llm
class GamificationView extends StatefulWidget {
const GamificationView({super.key});
@@ -26,20 +31,31 @@ class GamificationView extends StatefulWidget {
}
class _GamificationViewState extends State {
+ List assignedGames = [];
PlatformFile? _selectedFile;
String? _selectedGameType;
String? _selectedDifficulty;
- bool isTeacher = true;
bool _isGameCreated = false;
List>? _generatedGameData;
LlmType? _selectedLLM;
bool _gameNeedsRefresh = false;
+ bool _localLlmAvail = !kIsWeb;
+ bool _isLoadingAssignments = false;
+ final GamificationService _gamificationService = GamificationService();
+ String? _assignmentsError;
+ int _studentCompletedCount = 0;
+ bool _hasStudentScores = false;
+ final Map _courseNameCache = {};
+ final Map _studentNameCache = {};
+ bool _isClearingAssignments = false;
+ bool _coursesLoaded = false;
@override
void initState() {
super.initState();
_selectedLLM = LlmType.values
.firstWhereOrNull((llm) => LocalStorageService.userHasLlmKey(llm));
+ _refreshAssignments();
}
void showLoadingDialog(BuildContext context) {
@@ -58,13 +74,74 @@ class _GamificationViewState extends State {
);
}
+ Future _refreshAssignments() async {
+ final userIdStr = LocalStorageService.getUserId();
+ final role = LocalStorageService.getUserRole();
+ if (userIdStr == null || userIdStr.isEmpty) {
+ setState(() {
+ assignedGames = [];
+ _assignmentsError = 'User id not available.';
+ });
+ return;
+ }
+
+ final userId = int.tryParse(userIdStr);
+ if (userId == null) {
+ setState(() {
+ assignedGames = [];
+ _assignmentsError = 'Unable to parse user id.';
+ });
+ return;
+ }
+
+ setState(() {
+ _isLoadingAssignments = true;
+ _assignmentsError = null;
+ });
+
+ try {
+ List games;
+ if (role == UserRole.teacher) {
+ games = await _gamificationService.getGamesForTeacher(userId);
+ await _ensureCourseNames();
+ setState(() {
+ assignedGames = games;
+ _isLoadingAssignments = false;
+ });
+ } else {
+ games = await _gamificationService.getGamesForStudent(userId);
+ final completed = games.where((g) => g.score!.score != null).toList();
+ final pending = games.where((g) => g.score!.score == null).toList();
+ await _ensureCourseNames();
+ setState(() {
+ assignedGames = pending;
+ _studentCompletedCount = completed.length;
+ _hasStudentScores = completed.isNotEmpty;
+ _isLoadingAssignments = false;
+ });
+ }
+ } catch (e) {
+ setState(() {
+ assignedGames = [];
+ _assignmentsError = e.toString();
+ _isLoadingAssignments = false;
+ });
+ }
+ }
+
@override
Widget build(BuildContext context) {
bool isTeacher = LocalStorageService.getUserRole() ==
UserRole.teacher; // TEMP: Change to logic check later
return Scaffold(
- appBar: AppBar(title: const Text('Generate a Game')),
+ appBar: isTeacher
+ ? AppBar(title: const Text('Generate a Game'))
+ : AppBar(
+ title: const Text('🎮 My Games'),
+ backgroundColor: Colors.deepPurpleAccent,
+ centerTitle: true,
+ ),
body: Padding(
padding: const EdgeInsets.all(20.0),
child: isTeacher ? _buildTeacherUI(context) : _buildStudentUI(),
@@ -89,6 +166,7 @@ class _GamificationViewState extends State {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
+ withData: true,
);
if (result != null) {
@@ -170,17 +248,33 @@ class _GamificationViewState extends State {
items: LlmType.values.map((LlmType llm) {
return DropdownMenuItem(
value: llm,
- enabled: LocalStorageService.userHasLlmKey(llm),
+ enabled: (llm == LlmType.LOCAL &&
+ LocalStorageService.getLocalLLMPath() != "" &&
+ _localLlmAvail) ||
+ LocalStorageService.userHasLlmKey(llm),
child: Text(
llm.displayName,
style: TextStyle(
- color: LocalStorageService.userHasLlmKey(llm)
+ color: (llm == LlmType.LOCAL &&
+ LocalStorageService.getLocalLLMPath() != "" &&
+ _localLlmAvail) ||
+ LocalStorageService.userHasLlmKey(llm)
? Colors.black87
: Colors.grey,
),
),
);
}).toList()),
+ if (_selectedLLM == LlmType.LOCAL) ...[
+ const SizedBox(height: 6),
+ const Text(
+ "Running a Large Language Model (LLM) locally typically requires substantial hardware resources.\nWe recommend using 7B or higher thinking (Qwen) models to create the game. \nFor best results, we recommend using external LLM.\nPlease use the local LLM responsibly and independently verify any critical information.",
+ style: TextStyle(
+ fontSize: 13,
+ color: Colors.black54,
+ ),
+ ),
+ ],
const SizedBox(height: 40),
Center(
child: ElevatedButton(
@@ -205,15 +299,11 @@ class _GamificationViewState extends State {
showLoadingDialog(context);
try {
- final Uint8List? bytes;
- if (kIsWeb) {
- bytes = _selectedFile!.bytes;
- } else {
- bytes = File(_selectedFile!.path!).readAsBytesSync();
+ final Uint8List? bytes = _selectedFile!.bytes;
+ if (bytes == null) {
+ throw Exception("No file content found");
}
- if (bytes == null) throw Exception("No file content found");
-
final text = await AIFileService.extractTextFromPDF(bytes);
late final List> response;
@@ -224,6 +314,8 @@ class _GamificationViewState extends State {
aiModel = GrokLLM(LocalStorageService.getGrokKey());
} else if (_selectedLLM == LlmType.DEEPSEEK) {
aiModel = DeepseekLLM(LocalStorageService.getDeepseekKey());
+ } else if (_selectedLLM == LlmType.LOCAL) {
+ aiModel = LocalLLMService();
} else {
aiModel =
PerplexityLLM(LocalStorageService.getPerplexityKey());
@@ -232,7 +324,8 @@ class _GamificationViewState extends State {
if (_selectedLLM == LlmType.CHATGPT ||
_selectedLLM == LlmType.DEEPSEEK ||
_selectedLLM == LlmType.PERPLEXITY ||
- _selectedLLM == LlmType.GROK) {
+ _selectedLLM == LlmType.GROK ||
+ _selectedLLM == LlmType.LOCAL) {
if (_selectedGameType == 'Quiz Game') {
response = await generateGameFromText(text, aiModel);
} else if (_selectedGameType == 'Matching') {
@@ -262,7 +355,9 @@ class _GamificationViewState extends State {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Game Created!')),
+ const SnackBar(
+ content: Text(
+ 'Game generated! Preview or assign it when ready.')),
);
} catch (e) {
Navigator.pop(context);
@@ -299,13 +394,13 @@ class _GamificationViewState extends State {
case 'Quiz Game':
previewContent = QuizGame(
questions: gameData,
- onComplete: () {},
+ onComplete: (_) {},
previewMode: true,
);
case 'Matching':
previewContent = MatchingGame(
pairs: gameData,
- onComplete: () {},
+ onComplete: (_) {},
previewMode: true,
);
case 'Flashcards':
@@ -334,8 +429,52 @@ class _GamificationViewState extends State {
),
),
const SizedBox(height: 20),
+ Center(
+ child: ElevatedButton(
+ child: const Text('Assign Game to Students'),
+ onPressed: () {
+ _showAssignPopup(context);
+ },
+ ),
+ ),
+ const SizedBox(height: 20),
const Divider(),
],
+ const SizedBox(height: 20),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ OutlinedButton.icon(
+ icon: const Icon(Icons.leaderboard),
+ label: const Text('View Student Scores'),
+ onPressed: _showScoreboardDialog,
+ ),
+ if (LocalStorageService.getUserRole() == UserRole.teacher)
+ Padding(
+ padding: const EdgeInsets.only(left: 12.0),
+ child: OutlinedButton.icon(
+ icon: _isClearingAssignments
+ ? const SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.delete_forever),
+ label: Text(
+ _isClearingAssignments
+ ? 'Clearing...'
+ : 'Clear All Assigned Games',
+ ),
+ style: OutlinedButton.styleFrom(
+ foregroundColor: Colors.redAccent,
+ ),
+ onPressed: _isClearingAssignments
+ ? null
+ : _confirmAndClearAssignments,
+ ),
+ ),
+ ],
+ ),
],
),
);
@@ -360,8 +499,179 @@ class _GamificationViewState extends State {
}
Widget _buildStudentUI() {
- return const Center(
- child: Text('Game for kids Here'),
+ print('🧠 Student UI loading with ${assignedGames.length} assigned games.');
+ if (_isLoadingAssignments) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (_assignmentsError != null) {
+ return Center(
+ child: Text(
+ _assignmentsError!,
+ textAlign: TextAlign.center,
+ ),
+ );
+ }
+ if (assignedGames.isEmpty) {
+ return const Center(
+ child: Text('No games assigned yet.'),
+ );
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '👋 Welcome back, ready to play?',
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.bold,
+ color: Colors.deepPurple,
+ ),
+ ),
+ if (_hasStudentScores)
+ Padding(
+ padding: const EdgeInsets.only(top: 8),
+ child: Text(
+ 'Completed $_studentCompletedCount game${_studentCompletedCount == 1 ? '' : 's'}.',
+ style: const TextStyle(
+ fontSize: 14,
+ color: Colors.deepPurpleAccent,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ Expanded(
+ child: ListView.builder(
+ itemCount: assignedGames.length,
+ itemBuilder: (context, index) {
+ final game = assignedGames[index];
+ final emoji = _emojiForGameType(game.gameType);
+ final formattedDate =
+ DateFormat.yMMMd().format(game.assignedDate);
+ final courseName = _courseNameCache[game.courseId];
+ final gameTypeLabel = _labelForGameType(game.gameType);
+ final titleText = courseName != null && courseName.isNotEmpty
+ ? '$courseName: $gameTypeLabel'
+ : '${game.title}: $gameTypeLabel';
+ final subtitleParts = [];
+ if (courseName == null || courseName.isEmpty) {
+ subtitleParts.add('Course ID: ${game.courseId}');
+ } else if (!game.title
+ .toLowerCase()
+ .contains(courseName.toLowerCase())) {
+ subtitleParts.add(game.title);
+ }
+ subtitleParts.add('📅 Assigned: $formattedDate');
+ return Card(
+ color: Colors.deepPurple[50],
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: ListTile(
+ leading: Text(emoji, style: TextStyle(fontSize: 30)),
+ title: Text(
+ titleText,
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ subtitle: Text(subtitleParts.join('\n')),
+ trailing: ElevatedButton(
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.deepPurple,
+ foregroundColor: Colors.white,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(12),
+ ),
+ ),
+ onPressed: () {
+ final content = _decodeGameData(game.gameData);
+ if (content == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'No content found for this game. Ask your teacher to reassign it.')),
+ );
+ return;
+ }
+
+ final type =
+ _parseGameType(content['gameType'] ?? game.gameType);
+ final List> data =
+ List>.from(
+ content['data'] ?? const []);
+
+ if (data.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'Game content is empty. Ask your teacher to regenerate it.')),
+ );
+ return;
+ }
+
+ Widget gameView;
+ switch (type) {
+ case GameType.QUIZ:
+ gameView = QuizGame(
+ questions: data,
+ onComplete: (result) {
+ _recordGameResult(game, result);
+ },
+ previewMode: false,
+ );
+ case GameType.MATCHING:
+ gameView = MatchingGame(
+ pairs: data,
+ onComplete: (result) {
+ _recordGameResult(game, result);
+ },
+ previewMode: false,
+ );
+ case GameType.FLASHCARD:
+ gameView = FlashcardGame(
+ questions: data,
+ onComplete: () {
+ _recordGameResult(
+ game,
+ GamePlayResult(
+ score: data.length,
+ maxScore: data.length,
+ ),
+ );
+ },
+ previewMode: false,
+ );
+ }
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Play Game'),
+ content: SizedBox(width: 600, child: gameView),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ );
+ },
+ child: const Text('▶️ Play'),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
);
}
@@ -378,9 +688,20 @@ class _GamificationViewState extends State {
'Input: $text';
final raw = await aiModel.postToLlm(systemPrompt);
+ // Clean and isolate valid JSON from AI output
+ String cleaned = raw
+ .replaceAll(RegExp(r'```json', multiLine: true), '')
+ .replaceAll(RegExp(r'```', multiLine: true), '')
+ .trim();
+
+ // Extract only the first valid JSON array to avoid extra text
+ final match = RegExp(r'\[\s*{[\s\S]*?}\s*\]').firstMatch(cleaned);
+ if (match != null) {
+ cleaned = match.group(0)!;
+ }
try {
- final parsedList = _parseJsonList>(raw, (item) {
+ final parsedList = _parseJsonList>(cleaned, (item) {
if (item is Map) return Map.from(item);
throw Exception('Item is not an object');
});
@@ -388,7 +709,7 @@ class _GamificationViewState extends State {
return parsedList;
} catch (e) {
throw Exception(
- 'Response was not valid JSON (generateGameFromText). $e\nResponse:\n$raw');
+ 'Response was not valid JSON (generateGameFromText). $e\nResponse:\n$cleaned');
}
}
@@ -535,4 +856,744 @@ $text
throw Exception(
'Expected JSON array from model but got: ${decoded.runtimeType}');
}
+
+ // Assign game to all students in a course, preventing duplicate global assignments
+ Future assignGameToAllStudents(
+ String title,
+ String gameType,
+ int courseId, {
+ Set? specificStudentIds,
+ }) async {
+ if (_generatedGameData == null || _generatedGameData!.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content:
+ Text('Please generate the game content before assigning.')),
+ );
+ return false;
+ }
+ final lmsService = LmsFactory.getLmsService();
+ final students =
+ await lmsService.getCourseParticipants(courseId.toString());
+ final targetStudents = specificStudentIds == null
+ ? students
+ : students
+ .where((student) => specificStudentIds.contains(student.id))
+ .toList();
+
+ if (targetStudents.isEmpty) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('No students selected for this assignment.')),
+ );
+ return false;
+ }
+
+ final teacherIdStr = LocalStorageService.getUserId();
+ final teacherId = int.tryParse(teacherIdStr ?? '');
+ if (teacherId == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Unable to determine teacher id.')),
+ );
+ return false;
+ }
+
+ final now = DateTime.now();
+ final gameTypeEnum = _gameTypeFromLabel(gameType);
+ final contentPayload = jsonEncode({
+ 'gameType': gameType,
+ 'data': _generatedGameData ?? [],
+ });
+
+ try {
+ final assignedGame = AssignedGame(
+ uuid: null,
+ courseId: courseId,
+ gameType: gameTypeEnum,
+ title: title,
+ gameData: contentPayload,
+ assignedDate: now,
+ assignedBy: teacherId,
+ );
+ final gameResponse = await _gamificationService.createGame(assignedGame);
+ final responseBody = jsonDecode(gameResponse.body);
+ final gameId = responseBody[0]["game_id"];
+
+ await Future.wait(targetStudents.map((student) {
+ return _gamificationService
+ .assignGame(AssignedGameScore(studentId: student.id, game: gameId));
+ }));
+
+ await _refreshAssignments();
+
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Game "$title" assigned to ${targetStudents.length} student${targetStudents.length > 1 ? 's' : ''}.'),
+ ),
+ );
+ return true;
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to assign game: $e')),
+ );
+ return false;
+ }
+ }
+
+ // Show a popup/modal to assign game to students in a course
+ void _showAssignPopup(BuildContext context) async {
+ final selectedGameType = _selectedGameType;
+ final lmsService = LmsFactory.getLmsService();
+ List? courses;
+ int? selectedCourseId;
+ List? students;
+ Set selectedStudentIds = {};
+ bool isLoadingCourses = true;
+ bool isLoadingStudents = false;
+ bool isAssigning = false;
+
+ // Helper to refresh state in the dialog
+ // void refresh(void Function() fn) {
+ // // ignore: invalid_use_of_protected_member
+ // (context as Element).markNeedsBuild();
+ // fn();
+ // }
+
+ await showDialog(
+ context: context,
+ builder: (BuildContext dialogContext) {
+ // Use StatefulBuilder for local state
+ return StatefulBuilder(
+ builder: (context, setState) {
+ // On first build, load courses
+ if (isLoadingCourses) {
+ lmsService.getUserCourses().then((fetchedCourses) {
+ setState(() {
+ courses = fetchedCourses;
+ isLoadingCourses = false;
+ if (courses != null && courses!.isNotEmpty) {
+ selectedCourseId = courses!.first.id;
+ isLoadingStudents = true;
+ lmsService
+ .getCourseParticipants(selectedCourseId.toString())
+ .then((fetchedStudents) {
+ setState(() {
+ students = fetchedStudents;
+ isLoadingStudents = false;
+ selectedStudentIds.clear();
+ });
+ });
+ }
+ });
+ });
+ }
+
+ return AlertDialog(
+ title: const Text('Assign Game to Students'),
+ content: SizedBox(
+ width: 450,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (isLoadingCourses)
+ const Center(child: CircularProgressIndicator())
+ else if (courses == null || courses!.isEmpty)
+ const Text('No courses found.')
+ else ...[
+ const Text('Select Course:'),
+ DropdownButton(
+ value: selectedCourseId,
+ isExpanded: true,
+ items: courses!
+ .map((course) => DropdownMenuItem(
+ value: course.id,
+ child: Text(course.fullName),
+ ))
+ .toList(),
+ onChanged: (int? value) {
+ if (value == null) return;
+ setState(() {
+ selectedCourseId = value;
+ isLoadingStudents = true;
+ students = null;
+ selectedStudentIds.clear();
+ });
+ lmsService
+ .getCourseParticipants(value.toString())
+ .then((fetchedStudents) {
+ setState(() {
+ students = fetchedStudents;
+ isLoadingStudents = false;
+ selectedStudentIds.clear();
+ });
+ });
+ },
+ ),
+ const SizedBox(height: 16),
+ const Text('Select Students:'),
+ // "Select All Students" checkbox
+ CheckboxListTile(
+ title: const Text('Select All Students'),
+ value: students != null &&
+ students!.isNotEmpty &&
+ selectedStudentIds.length == students!.length,
+ onChanged: (checked) {
+ setState(() {
+ if (checked == true) {
+ selectedStudentIds = students!
+ .map((student) => student.id)
+ .toSet();
+ } else {
+ selectedStudentIds.clear();
+ }
+ });
+ },
+ ),
+ if (isLoadingStudents)
+ const Center(child: CircularProgressIndicator())
+ else if (students == null || students!.isEmpty)
+ const Text('No students found for this course.')
+ else
+ SizedBox(
+ height: 200,
+ child: Scrollbar(
+ child: ListView(
+ children: students!
+ .map((student) => CheckboxListTile(
+ value: selectedStudentIds
+ .contains(student.id),
+ title: Text(
+ '${student.firstname} ${student.lastname}'),
+ onChanged: (checked) {
+ setState(() {
+ if (checked == true) {
+ selectedStudentIds
+ .add(student.id);
+ } else {
+ selectedStudentIds
+ .remove(student.id);
+ }
+ });
+ },
+ ))
+ .toList(),
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Cancel'),
+ ),
+ ElevatedButton(
+ onPressed: (isAssigning ||
+ isLoadingCourses ||
+ isLoadingStudents ||
+ selectedStudentIds.isEmpty ||
+ selectedCourseId == null)
+ ? null
+ : () async {
+ setState(() {
+ isAssigning = true;
+ });
+
+ if (_gameNeedsRefresh || _generatedGameData == null) {
+ setState(() {
+ isAssigning = false;
+ });
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text(
+ 'Please regenerate the game before assigning.')),
+ );
+ return;
+ }
+
+ final title = 'Generated Game: $selectedGameType';
+ final gameType = selectedGameType ?? 'Unknown';
+ final courseId = selectedCourseId!;
+ final didAssign = await assignGameToAllStudents(
+ title,
+ gameType,
+ courseId,
+ specificStudentIds: selectedStudentIds,
+ );
+ setState(() {
+ isAssigning = false;
+ });
+ if (didAssign) {
+ Navigator.of(dialogContext).pop();
+ }
+ },
+ child: isAssigning
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Text('Assign Game'),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ }
+
+ // String _serializeGameType(GameType type) {
+ // switch (type) {
+ // case GameType.QUIZ:
+ // return 'Quiz Game';
+ // case GameType.MATCHING:
+ // return 'Matching';
+ // case GameType.FLASHCARD:
+ // return 'Flashcards';
+ // }
+ // }
+
+ Future _confirmAndClearAssignments() async {
+ final role = LocalStorageService.getUserRole();
+ if (role != UserRole.teacher) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Only teachers can clear assignments.')),
+ );
+ return;
+ }
+
+ final confirmed = await showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Clear Assigned Games'),
+ content: const Text(
+ 'This will remove all games you have assigned from the database. '
+ 'Students will no longer see them. Continue?',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(false),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(true),
+ child: const Text('Clear'),
+ ),
+ ],
+ ),
+ ) ??
+ false;
+
+ if (!confirmed) return;
+
+ final teacherIdStr = LocalStorageService.getUserId();
+ final teacherId = int.tryParse(teacherIdStr ?? '');
+ if (teacherId == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Unable to determine teacher id.')),
+ );
+ return;
+ }
+
+ setState(() {
+ _isClearingAssignments = true;
+ });
+
+ try {
+ final games = await _gamificationService.getGamesForTeacher(teacherId);
+ final deletions = games
+ .where((game) => game.uuid != null)
+ .map((game) => _gamificationService.deleteGame(game.uuid!));
+ await Future.wait(deletions);
+ await _refreshAssignments();
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Cleared ${games.length} assigned game${games.length == 1 ? '' : 's'}'),
+ ),
+ );
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to clear assignments: $e')),
+ );
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isClearingAssignments = false;
+ });
+ }
+ }
+ }
+
+ GameType _parseGameType(dynamic value) {
+ if (value is GameType) return value;
+ if (value is int) {
+ if (value >= 0 && value < GameType.values.length) {
+ return GameType.values[value];
+ }
+ }
+ final raw = value?.toString().toLowerCase() ?? '';
+ if (raw.contains('match')) return GameType.MATCHING;
+ if (raw.contains('flash')) return GameType.FLASHCARD;
+ return GameType.QUIZ;
+ }
+
+ GameType _gameTypeFromLabel(String label) {
+ final normalized = label.toLowerCase();
+ if (normalized.contains('match')) return GameType.MATCHING;
+ if (normalized.contains('flash')) return GameType.FLASHCARD;
+ return GameType.QUIZ;
+ }
+
+ Map? _decodeGameData(String raw) {
+ if (raw.isEmpty) return null;
+ try {
+ final decoded = jsonDecode(raw);
+ if (decoded is Map) return decoded;
+ if (decoded is Map) {
+ return decoded.map((key, value) => MapEntry(key.toString(), value));
+ }
+ return null;
+ } catch (e) {
+ debugPrint('⚠️ Failed to decode game data: $e');
+ return null;
+ }
+ }
+
+ String _emojiForGameType(GameType type) {
+ switch (type) {
+ case GameType.QUIZ:
+ return '🧩';
+ case GameType.MATCHING:
+ return '🔗';
+ case GameType.FLASHCARD:
+ return '🃏';
+ }
+ }
+
+ String _labelForGameType(GameType type) {
+ switch (type) {
+ case GameType.QUIZ:
+ return 'Quiz Game';
+ case GameType.MATCHING:
+ return 'Matching Game';
+ case GameType.FLASHCARD:
+ return 'Flashcards';
+ }
+ }
+
+ // double _scorePercentFromGame(AssignedGame game) {
+ // if (game.maxScore != null &&
+ // game.maxScore! > 0 &&
+ // game.rawCorrect != null) {
+ // return (game.rawCorrect! / game.maxScore!) * 100.0;
+ // }
+ // final raw = game.score ?? 0;
+ // final percent = raw <= 1 ? raw * 100 : raw;
+ // return percent;
+ // }
+
+ Future _recordGameResult(
+ AssignedGame game, GamePlayResult result) async {
+ if (!mounted || game.uuid == null) return;
+ final normalizedScore =
+ result.maxScore == 0 ? 0.0 : result.score / result.maxScore;
+ try {
+ final response = await _gamificationService.completeGame(
+ game.uuid!,
+ game.score!.studentId,
+ normalizedScore,
+ rawCorrect: result.score,
+ maxScore: result.maxScore,
+ );
+ if (response.statusCode != 200) {
+ throw Exception('Server returned ${response.statusCode}');
+ }
+ await _refreshAssignments();
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ 'Score saved: ${(normalizedScore * 100).toStringAsFixed(0)}%',
+ ),
+ ),
+ );
+ } catch (e) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to save score: $e')),
+ );
+ }
+ }
+
+ Future> _buildStudentNameMap(
+ List games) async {
+ final names = Map.from(_studentNameCache);
+ final lmsService = LmsFactory.getLmsService();
+ final uniqueCourseIds = games.map((g) => g.courseId).toSet();
+
+ for (final courseId in uniqueCourseIds) {
+ try {
+ final participants =
+ await lmsService.getCourseParticipants(courseId.toString());
+ for (final participant in participants) {
+ final fullName =
+ '${participant.firstname} ${participant.lastname}'.trim();
+ if (fullName.isNotEmpty) {
+ names[participant.id] = fullName;
+ }
+ }
+ } catch (_) {
+ // Ignore failures; we'll fall back to the student id label.
+ }
+ }
+
+ if (mounted) {
+ setState(() {
+ _studentNameCache.addAll(names);
+ });
+ }
+ return names;
+ }
+
+ Future _ensureCourseNames() async {
+ if (_coursesLoaded) return;
+ final lmsService = LmsFactory.getLmsService();
+ try {
+ final courses = await lmsService.getUserCourses();
+ for (final course in courses) {
+ _courseNameCache[course.id] = course.fullName;
+ }
+ _coursesLoaded = true;
+ } catch (e) {
+ debugPrint('⚠️ Failed to load course names: $e');
+ }
+ }
+
+ void _showScoreboardDialog() {
+ if (LocalStorageService.getUserRole() != UserRole.teacher) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Only teachers can view the scoreboard.')),
+ );
+ return;
+ }
+
+ final teacherIdStr = LocalStorageService.getUserId();
+ final teacherId = int.tryParse(teacherIdStr ?? '');
+ if (teacherId == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Unable to determine teacher id.')),
+ );
+ return;
+ }
+
+ showDialog(
+ context: context,
+ barrierDismissible: true,
+ builder: (context) {
+ return FutureBuilder>(
+ future: _gamificationService.getGamesForTeacher(teacherId),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const AlertDialog(
+ content: SizedBox(
+ height: 80,
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ );
+ }
+
+ if (snapshot.hasError) {
+ return AlertDialog(
+ title: const Text('Student Scores'),
+ content: Text('Failed to load scores: ${snapshot.error}'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ );
+ }
+
+ final games = snapshot.data ?? [];
+ if (games.isEmpty) {
+ return AlertDialog(
+ title: const Text('Student Scores'),
+ content: const Text('No assignments found yet.'),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ );
+ }
+
+ return FutureBuilder>(
+ future: _buildStudentNameMap(games),
+ builder: (context, nameSnapshot) {
+ if (nameSnapshot.connectionState == ConnectionState.waiting) {
+ return const AlertDialog(
+ content: SizedBox(
+ height: 80,
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ );
+ }
+
+ final nameMap = nameSnapshot.data ??
+ Map.from(_studentNameCache);
+
+ final groupedByStudent = >{};
+ for (final game in games) {
+ groupedByStudent
+ .putIfAbsent(game.score!.studentId, () => [])
+ .add(game);
+ }
+
+ final rows = <_ScoreRow>[];
+ for (final entry in groupedByStudent.entries) {
+ final studentId = entry.key;
+ final studentName =
+ nameMap[studentId] ?? 'Student $studentId';
+ final studentGames = entry.value
+ ..sort((a, b) => b.assignedDate.compareTo(a.assignedDate));
+ for (final game in studentGames) {
+ final hasRaw = game.score!.rawCorrect != null &&
+ game.score!.maxScore != null &&
+ game.score!.maxScore! > 0;
+ final isCompleted = game.score!.score != null;
+ final statusText = isCompleted
+ ? hasRaw
+ ? 'Completed ${game.score!.rawCorrect}/${game.score!.maxScore}'
+ : 'Completed'
+ : 'Pending';
+ rows.add(
+ _ScoreRow(
+ studentName: studentName,
+ gameTitle: game.title,
+ gameType: _labelForGameType(game.gameType),
+ statusText: statusText,
+ assignedDate: game.assignedDate,
+ isCompleted: isCompleted,
+ ),
+ );
+ }
+ }
+
+ return AlertDialog(
+ title: const Text('Student Scores'),
+ content: SizedBox(
+ width: 560,
+ child: rows.isEmpty
+ ? const Text('No scored assignments yet.')
+ : SingleChildScrollView(
+ scrollDirection: Axis.vertical,
+ child: DataTable(
+ columnSpacing: 16,
+ headingRowHeight: 36,
+ dataRowHeight: 40,
+ columns: const [
+ DataColumn(
+ label: Text(
+ 'Student',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ DataColumn(
+ label: Text(
+ 'Game',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ DataColumn(
+ label: Text(
+ 'Type',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ DataColumn(
+ label: Text(
+ 'Status',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ DataColumn(
+ label: Text(
+ 'Assigned',
+ style:
+ TextStyle(fontWeight: FontWeight.w600),
+ ),
+ ),
+ ],
+ rows: rows
+ .map(
+ (row) => DataRow(
+ cells: [
+ DataCell(Text(row.studentName)),
+ DataCell(Text(row.gameTitle)),
+ DataCell(Text(row.gameType)),
+ DataCell(
+ Text(
+ row.statusText,
+ style: TextStyle(
+ color: row.isCompleted
+ ? Colors.green.shade700
+ : Colors.orange.shade700,
+ ),
+ ),
+ ),
+ DataCell(
+ Text(
+ DateFormat.yMMMd()
+ .format(row.assignedDate),
+ ),
+ ),
+ ],
+ ),
+ )
+ .toList(),
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ );
+ },
+ );
+ }
+}
+
+class _ScoreRow {
+ final String studentName;
+ final String gameTitle;
+ final String gameType;
+ final String statusText;
+ final DateTime assignedDate;
+ final bool isCompleted;
+
+ const _ScoreRow({
+ required this.studentName,
+ required this.gameTitle,
+ required this.gameType,
+ required this.statusText,
+ required this.assignedDate,
+ required this.isCompleted,
+ });
}
diff --git a/LearningLens2025/frontend/lib/Views/iep_page.dart b/LearningLens2025/frontend/lib/Views/iep_page.dart
index e7d5149b..044c9886 100644
--- a/LearningLens2025/frontend/lib/Views/iep_page.dart
+++ b/LearningLens2025/frontend/lib/Views/iep_page.dart
@@ -11,7 +11,6 @@ import 'package:learninglens_app/Api/llm/llm_api_modules_base.dart';
import 'package:learninglens_app/Api/llm/openai_api.dart';
import 'package:learninglens_app/Api/llm/perplexity_api.dart';
import "package:learninglens_app/Api/lms/factory/lms_factory.dart";
-import "package:learninglens_app/Api/lms/moodle/moodle_lms_service.dart";
import "package:learninglens_app/Controller/custom_appbar.dart";
import 'package:learninglens_app/Controller/html_converter.dart';
import 'package:learninglens_app/beans/assessment.dart';
@@ -61,7 +60,7 @@ class _IepPageState extends State {
@override
void initState() {
super.initState();
- overrides = MoodleLmsService().overrides;
+ overrides = LmsFactory.getLmsService().overrides;
overrides?.sort((a, b) => a.fullname.compareTo(b.fullname));
selectedLLM = LlmType.values
.firstWhereOrNull((llm) => LocalStorageService.userHasLlmKey(llm));
@@ -361,7 +360,7 @@ class _IepPageState extends State {
child: Align(
alignment: AlignmentGeometry.topRight,
child: Text(
- "Running a Large Language Model (LLM) requires substantial hardware resources.\nThe recommended model for this task is 7B or higher reasoning models (Qwen). Using smaller models may produce inaccurate or misleading responses.\nFor optimal results, we recommend using the external API.\nPlease use the local LLM responsibly and independently verify any critical information.",
+ "Running a Large Language Model (LLM) locally typically requires substantial hardware resources.\nThe recommended model for this task is 7B or higher reasoning models (Qwen). Using smaller models may produce inaccurate or misleading responses.\nFor best results, we recommend using the external LLM.\nPlease use the local LLM responsibly and independently verify any critical information.",
style: TextStyle(
fontSize: 13,
color: Colors.black54,
@@ -561,14 +560,22 @@ class _IepPageState extends State {
attempts != null) &&
(selectedAssignment?.type != "essay" ||
epochTime2 != null)
- ? () {
+ ? () async {
if (selectedAssignment?.type == 'quiz') {
- quizOver(epochTime!, selectedAssignment!.id,
- userId!, attempts!);
+ await quizOver(
+ epochTime!,
+ int.parse(selectedCourse!),
+ selectedAssignment!.id,
+ userId!,
+ attempts!);
} else if (selectedAssignment?.type ==
'essay') {
- essayOver(epochTime!, selectedAssignment!.id,
- userId!, epochTime2!);
+ await essayOver(
+ epochTime!,
+ int.parse(selectedCourse!),
+ selectedAssignment!.id,
+ userId!,
+ epochTime2!);
}
resetForm(false);
}
@@ -697,13 +704,14 @@ class _IepPageState extends State {
List? getAllCourses() {
List? result;
- result = MoodleLmsService().courses;
+ result = LmsFactory.getLmsService().courses;
return result;
}
Future>? getAllParticipants(String courseID) async {
List? participants;
- participants = await MoodleLmsService().getCourseParticipants(courseID);
+ participants =
+ await LmsFactory.getLmsService().getCourseParticipants(courseID);
return participants;
}
@@ -723,11 +731,12 @@ class _IepPageState extends State {
Future> handleAssessmentSelection(int? courseID) async {
if (courseID != null) {
- List essayList = await MoodleLmsService().getEssays(courseID);
+ List essayList =
+ await LmsFactory.getLmsService().getEssays(courseID);
// Fetch quizzes (if available).
List quizList = [];
try {
- quizList = await MoodleLmsService().getQuizzes(courseID);
+ quizList = await LmsFactory.getLmsService().getQuizzes(courseID);
} catch (e) {
print("getQuizzes not available or failed: $e");
}
@@ -760,15 +769,17 @@ class _IepPageState extends State {
});
}
- void quizOver(int epochTime, int quizId, int userId, int attempts) async {
- await MoodleLmsService().addQuizOverride(
+ Future quizOver(
+ int epochTime, int courseId, int quizId, int userId, int attempts) async {
+ await LmsFactory.getLmsService().addQuizOverride(
quizId: quizId,
+ courseId: courseId,
userId: userId,
timeClose: epochTime,
attempts: attempts);
- await MoodleLmsService().refreshOverrides();
+ await LmsFactory.getLmsService().refreshOverrides();
setState(() {
- overrides = MoodleLmsService().overrides;
+ overrides = LmsFactory.getLmsService().overrides;
overrides?.sort((a, b) => a.fullname.compareTo(b.fullname));
});
ScaffoldMessenger.of(context).showSnackBar(
@@ -776,15 +787,17 @@ class _IepPageState extends State {
);
}
- void essayOver(int epochTime, int essayId, int userId, int epochTime2) async {
- await MoodleLmsService().addEssayOverride(
+ Future essayOver(int epochTime, int courseId, int essayId, int userId,
+ int epochTime2) async {
+ await LmsFactory.getLmsService().addEssayOverride(
assignid: essayId,
+ courseId: courseId,
userId: userId,
dueDate: epochTime,
cutoffDate: epochTime2);
- await MoodleLmsService().refreshOverrides();
+ await LmsFactory.getLmsService().refreshOverrides();
setState(() {
- overrides = MoodleLmsService().overrides;
+ overrides = LmsFactory.getLmsService().overrides;
overrides?.sort((a, b) => a.fullname.compareTo(b.fullname));
});
ScaffoldMessenger.of(context).showSnackBar(
diff --git a/LearningLens2025/frontend/lib/Views/lesson_plans.dart b/LearningLens2025/frontend/lib/Views/lesson_plans.dart
index 63675f50..30aab2ff 100644
--- a/LearningLens2025/frontend/lib/Views/lesson_plans.dart
+++ b/LearningLens2025/frontend/lib/Views/lesson_plans.dart
@@ -330,7 +330,7 @@ class _LessonPlanState extends State {
if (selectedLLM == LlmType.LOCAL) ...[
const SizedBox(height: 6),
const Text(
- "Running a Large Language Model (LLM) requires substantial hardware resources. The recommended model for this task is 7B or higher reasoning models (Qwen), however smaller models may be used without generating errors. Smaller models may produce inaccurate or misleading responses.\nFor optimal results, we recommend using the external API.\nPlease use the local LLM responsibly and independently verify any critical information.",
+ "Running a Large Language Model (LLM) locally typically requires substantial hardware resources.\nThe recommended model for this task is 3B or higher general/chat models. Using smaller models may produce inaccurate or misleading responses.\nFor best results, we recommend using the external API.\nPlease use the local LLM responsibly and independently verify any critical information.",
style: TextStyle(
fontSize: 13,
color: Colors.black54,
@@ -393,30 +393,23 @@ class _LessonPlanState extends State {
if (selectedLLM ==
LlmType.LOCAL)
TextButton(
- onPressed: () {
- setState(() {
- isGeneratingLesson =
- false;
- });
- // Cancel inference from Local LLM (may break)
- LocalLLMService()
- .cancel();
+ onPressed: () async {
+ await LocalLLMService()
+ .showCancelConfirmationDialog();
},
style:
TextButton.styleFrom(
foregroundColor:
- Colors.black,
- padding:
- EdgeInsets.zero,
- textStyle:
- const TextStyle(
- decoration:
- TextDecoration
- .underline,
- ),
+ Colors.redAccent,
),
child: const Text(
- 'Cancel Generation'),
+ 'Cancel Generation',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight:
+ FontWeight
+ .w500),
+ ),
),
],
)
diff --git a/LearningLens2025/frontend/lib/Views/program_assessment_form.dart b/LearningLens2025/frontend/lib/Views/program_assessment_form.dart
index 4f37811a..dbb930b8 100644
--- a/LearningLens2025/frontend/lib/Views/program_assessment_form.dart
+++ b/LearningLens2025/frontend/lib/Views/program_assessment_form.dart
@@ -209,6 +209,14 @@ class _ProgramAssessmentFormState extends State {
String expectedOutput,
String language,
int timeoutSeconds) async {
+ // Maximum timeout cannot be more than 2 minutes
+ if (timeoutSeconds > 120) {
+ _showSnackBar(SnackBar(
+ backgroundColor: Colors.red[700],
+ content: Text('Maximum timeout cannot be longer than 2 minutes')));
+ return;
+ }
+
if (!(await isFilesValid()) || !(await _confirmStart(course, assignment))) {
return;
}
diff --git a/LearningLens2025/frontend/lib/Views/program_assessment_results_view.dart b/LearningLens2025/frontend/lib/Views/program_assessment_results_view.dart
index 4c487a6d..34a1f1ef 100644
--- a/LearningLens2025/frontend/lib/Views/program_assessment_results_view.dart
+++ b/LearningLens2025/frontend/lib/Views/program_assessment_results_view.dart
@@ -7,6 +7,7 @@ import 'package:learninglens_app/beans/assignment.dart';
import 'package:learninglens_app/beans/course.dart';
import 'package:learninglens_app/beans/participant.dart';
import 'package:learninglens_app/services/program_assessment_service.dart';
+import 'package:url_launcher/url_launcher.dart';
class ProgramAsessmentResultsView extends StatefulWidget {
final ProrgramAssessmentJob evaluation;
@@ -35,6 +36,7 @@ class _ProgramAsessmentResultsViewState
final Course course;
final Assignment assignment;
final List participants;
+ late List> _submissionAttachments;
final lmsService = LmsFactory.getLmsService();
@@ -74,15 +76,46 @@ class _ProgramAsessmentResultsViewState
}
}
+ Future>> _getAssignmentAttachments() async {
+ return await lmsService.getSubmissionAttachments(assignId: assignment.id);
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
- backgroundColor: Theme.of(context).colorScheme.surface,
- appBar: CustomAppBar(
- title: 'Evaluation for ${assignment.name}',
- userprofileurl: lmsService.profileImage ?? '',
- ),
- body: SingleChildScrollView(child: _buildMainLayout()));
+ backgroundColor: Theme.of(context).colorScheme.surface,
+ appBar: CustomAppBar(
+ title: 'Evaluation for ${assignment.name}',
+ userprofileurl: lmsService.profileImage ?? '',
+ ),
+ body: FutureBuilder>>(
+ future: _getAssignmentAttachments(),
+ builder: (context, snapshot) {
+ if (snapshot.connectionState == ConnectionState.waiting) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ } else if (snapshot.hasError) {
+ return Center(
+ child: Text(
+ 'Error loading submissions: ${snapshot.error}',
+ style: const TextStyle(color: Colors.red),
+ ),
+ );
+ } else if (!snapshot.hasData || snapshot.data!.isEmpty) {
+ return const Center(
+ child: Text('No submissions found.'),
+ );
+ } else {
+ // Assign the result once loaded (optional, if you use it later)
+ _submissionAttachments = snapshot.data!;
+ return SingleChildScrollView(
+ child: _buildMainLayout(),
+ );
+ }
+ },
+ ),
+ );
}
Widget _buildMainLayout() {
@@ -132,15 +165,47 @@ class _ProgramAsessmentResultsViewState
);
}
+ Widget _buildViewSubmissionLink(String submissionUrl) {
+ return InkWell(
+ onTap: () async {
+ final uri = Uri.parse(submissionUrl);
+ if (await canLaunchUrl(uri)) {
+ await launchUrl(uri, mode: LaunchMode.externalApplication);
+ } else {
+ debugPrint('Could not launch $submissionUrl');
+ }
+ },
+ child: const Text(
+ 'View Submission',
+ style: TextStyle(
+ color: Colors.blue,
+ decoration: TextDecoration.underline,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ );
+ }
+
Widget _buildPanel(int idx, dynamic result) {
List outputs = result['outputs'];
final student = participants
.firstWhere((p) => p.id.toString() == result['studentId'].toString());
+
+ final studentSubmission = _submissionAttachments.firstWhere(
+ (entry) => entry['userid'].toString() == student.id.toString(),
+ );
+
final allOutputCorrectness = outputs.map(_isOutputCorrect);
List children = [];
+ // Add link to view the student's submission
+ children.addAll([
+ _buildViewSubmissionLink(studentSubmission['submissionUrl']),
+ SizedBox(height: 4)
+ ]);
+
final suggestedGrade = allOutputCorrectness.where((o) => o == true).length /
allOutputCorrectness.length;
diff --git a/LearningLens2025/frontend/lib/Views/quiz_generator.dart b/LearningLens2025/frontend/lib/Views/quiz_generator.dart
index 2d00d6f9..9716ebdc 100644
--- a/LearningLens2025/frontend/lib/Views/quiz_generator.dart
+++ b/LearningLens2025/frontend/lib/Views/quiz_generator.dart
@@ -379,7 +379,7 @@ class _AssessmentState extends State {
if (selectedLLM == LlmType.LOCAL) ...[
const SizedBox(height: 6),
const Text(
- "Running a Large Language Model (LLM) requires substantial hardware resources.\nThe recommended model for this task is 7B or higher reasoning models (Qwen). Using smaller models may produce inaccurate or misleading responses.\nFor optimal results, we recommend using the external API.\nPlease use the local LLM responsibly and independently verify any critical information.",
+ "Running a Large Language Model (LLM) locally typically requires substantial hardware resources.\nThe recommended model for this task is 7B or higher reasoning models (Qwen). Using smaller models may produce inaccurate or misleading responses.\nFor optimal results, we recommend using the external LLM.\nPlease use the local LLM responsibly and independently verify any critical information.",
style: TextStyle(
fontSize: 13,
color: Colors.black54,
diff --git a/LearningLens2025/frontend/lib/Views/send_essay_to_moodle.dart b/LearningLens2025/frontend/lib/Views/send_essay_to_moodle.dart
index 0e4567cd..99ac2af1 100644
--- a/LearningLens2025/frontend/lib/Views/send_essay_to_moodle.dart
+++ b/LearningLens2025/frontend/lib/Views/send_essay_to_moodle.dart
@@ -3,8 +3,11 @@ import 'package:learninglens_app/Api/lms/factory/lms_factory.dart';
import 'package:learninglens_app/Controller/custom_appbar.dart';
import 'package:learninglens_app/beans/course.dart';
import 'package:learninglens_app/Views/dashboard.dart';
+import 'package:learninglens_app/Views/edit_reflection_questions_page.dart';
import 'dart:convert';
+import 'package:learninglens_app/services/reflection_service.dart';
+
class EssayAssignmentSettings extends StatefulWidget {
final String updatedJson;
final String description;
@@ -18,6 +21,7 @@ class EssayAssignmentSettings extends StatefulWidget {
class EssayAssignmentSettingsState extends State {
// Global key for the form
final _formKey = GlobalKey();
+ List reflectionQuestions = [];
// Date selection variables for "Allow submissions from"
String selectedDaySubmission = '01';
@@ -232,8 +236,7 @@ class EssayAssignmentSettingsState extends State {
double screenWidth = constraints.maxWidth;
// Example: Calculate sizes dynamically based on screen width
- double buttonWidth =
- screenWidth * 0.4; // Buttons take 40% of screen width
+ double buttonWidth = 275.0;
double descriptionHeight = screenWidth *
0.2; // Description box takes 20% of screen width height
@@ -471,7 +474,7 @@ class EssayAssignmentSettingsState extends State {
String allowSubmissionFrom =
'$selectedDaySubmission $selectedMonthSubmission $selectedYearSubmission $selectedHourSubmission:$selectedMinuteSubmission';
- await api.createAssignment(
+ final result = await api.createAssignment(
courseId,
sectionNumber, // Section ID
assignmentName,
@@ -481,6 +484,16 @@ class EssayAssignmentSettingsState extends State {
description,
);
+ print(result);
+
+ for (String r in reflectionQuestions) {
+ await ReflectionService().createReflection(
+ Reflection(
+ courseId: int.parse(courseId),
+ assignmentId: result?['assignmentid'],
+ question: r));
+ }
+
if (mounted) {
final snackBar = SnackBar(
content: Text(
@@ -526,6 +539,53 @@ class EssayAssignmentSettingsState extends State {
child: Text('Go Back to Edit Essay'),
),
),
+ SizedBox(
+ width: buttonWidth,
+ child: ElevatedButton.icon(
+ onPressed: () async {
+ // Get course ID from selected course
+ Course? selectedCourseObj = courses.firstWhere(
+ (c) => c.fullName == selectedCourse,
+ orElse: () => Course(0, '', '', '',
+ DateTime.now(), DateTime.now()));
+ if (selectedCourseObj.id == 0) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content:
+ Text('Please select a course first.')),
+ );
+ return;
+ }
+
+ String assignmentId =
+ _assignmentNameController.text.isNotEmpty
+ ? _assignmentNameController.text
+ : 'temp_assignment';
+
+ final updatedQuestions = await Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) =>
+ EditReflectionQuestionsPage(
+ courseId: selectedCourseObj.id.toString(),
+ assignmentId: assignmentId,
+ initialQuestions: [],
+ ),
+ ),
+ );
+
+ if (updatedQuestions != null &&
+ updatedQuestions is List) {
+ // Store the questions locally in state
+ setState(() {
+ reflectionQuestions = updatedQuestions;
+ });
+ }
+ },
+ icon: Icon(Icons.edit),
+ label: Text('Edit Reflection Questions'),
+ ),
+ ),
],
),
],
diff --git a/LearningLens2025/frontend/lib/Views/student_reflections_page.dart b/LearningLens2025/frontend/lib/Views/student_reflections_page.dart
new file mode 100644
index 00000000..ed1bf962
--- /dev/null
+++ b/LearningLens2025/frontend/lib/Views/student_reflections_page.dart
@@ -0,0 +1,339 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_html/flutter_html.dart';
+import 'package:learninglens_app/services/reflection_service.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+// Api
+import 'package:learninglens_app/Api/lms/factory/lms_factory.dart';
+
+// beans
+import 'package:learninglens_app/beans/assignment.dart';
+import 'package:learninglens_app/beans/course.dart';
+
+enum ReflectionStatus { notStarted, inProgress, submitted }
+
+class StudentReflectionsPage extends StatefulWidget {
+ @override
+ State createState() => _StudentReflectionsPageState();
+}
+
+class _StudentReflectionsPageState extends State {
+ List _essays = [];
+ int _selectedSidebarIndex = -1;
+ bool _isReloadingEssays = false;
+
+ // Keep track of each essay’s reflection status
+ final Map _essayStatus = {};
+
+ // Track reflection answers for each essay
+ final Map> _controllers = {};
+
+ @override
+ void initState() {
+ super.initState();
+ _loadEssays();
+ }
+
+ Future _loadEssays({int? courseId}) async {
+ setState(() => _isReloadingEssays = true);
+
+ try {
+ final allEssays = await getAllEssays(courseId);
+
+ final prefs = await SharedPreferences.getInstance();
+ final uid = prefs.getString('userId');
+ final int? uidInt = uid != null ? int.tryParse(uid) : null;
+
+ final filteredEssays = allEssays.where((a) => !_isOverdue(a)).toList()
+ ..sort((a, b) {
+ final ad = _effectiveDue(a);
+ final bd = _effectiveDue(b);
+ if (ad == null && bd == null) return 0;
+ if (ad == null) return 1;
+ if (bd == null) return -1;
+ return ad.compareTo(bd);
+ });
+
+ for (var e in filteredEssays) {
+ ReflectionStatus stat = ReflectionStatus.notStarted;
+ final refs = await ReflectionService()
+ .getReflectionsForAssignment(e.courseId, e.id);
+ _controllers[e.id.toString()] = {};
+ for (var r in refs) {
+ ReflectionResponse? response;
+ if (uidInt != null) {
+ response = await ReflectionService()
+ .getReflectionForSubmission(r.uuid!, uidInt);
+ if (response != null) {
+ stat = ReflectionStatus.submitted;
+ }
+ }
+ setState(() {
+ _controllers[e.id.toString()]![r] =
+ TextEditingController(text: response?.response ?? "");
+ });
+ }
+ setState(() {
+ _essayStatus[e.id.toString()] = stat;
+ });
+ }
+
+ setState(() {
+ _essays = filteredEssays;
+ });
+
+ setState(() => _isReloadingEssays = false);
+ } catch (e) {
+ print("Error loading essays: $e");
+ }
+ }
+
+ bool _isOverdue(Assignment a) =>
+ a.dueDate != null && a.dueDate!.isBefore(DateTime.now());
+ DateTime? _effectiveDue(Assignment a) => a.dueDate;
+
+ String _formatDate(DateTime? date) {
+ if (date == null) return 'No due date';
+ return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
+ }
+
+ void _startReflection(Assignment essay) async {
+ final id = essay.id.toString();
+ setState(() {
+ _essayStatus[id] = ReflectionStatus.inProgress;
+ });
+ }
+
+ void _submitReflection(Assignment essay) async {
+ final id = essay.id.toString();
+ final controllers = _controllers[id];
+ final prefs = await SharedPreferences.getInstance();
+ final uid = prefs.getString('userId');
+ final int? uidInt = uid != null ? int.tryParse(uid) : null;
+ if (controllers == null || uidInt == null) return;
+
+ for (var c in controllers.entries) {
+ await ReflectionService().completeReflection(ReflectionResponse(
+ studentId: uidInt,
+ response: c.value.text.trim(),
+ reflectionId: c.key.uuid!));
+ }
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Reflection submitted.')),
+ );
+
+ setState(() {
+ _essayStatus[id] = ReflectionStatus.submitted;
+ });
+ }
+
+ Widget _statusChip(Assignment a) {
+ final status = _essayStatus[a.id.toString()] ?? ReflectionStatus.notStarted;
+ switch (status) {
+ case ReflectionStatus.notStarted:
+ return const Chip(label: Text('Not Started'));
+ case ReflectionStatus.inProgress:
+ return Chip(
+ label: const Text('In Progress'),
+ backgroundColor: Colors.amber.withOpacity(.2));
+ case ReflectionStatus.submitted:
+ return Chip(
+ label: const Text('Submitted'),
+ backgroundColor: Colors.green.withOpacity(.2));
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final selectedEssay =
+ _selectedSidebarIndex >= 0 ? _essays[_selectedSidebarIndex] : null;
+
+ return Scaffold(
+ appBar: AppBar(title: const Text('Student Reflections')),
+ body: Row(
+ children: [
+ // Left Sidebar: Essay List
+ SizedBox(
+ width: 280,
+ child: Material(
+ color: Theme.of(context).colorScheme.surface,
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
+ child: Row(
+ children: [
+ const Icon(Icons.assignment_outlined, size: 20),
+ const SizedBox(width: 8),
+ Text('Essay Assignments',
+ style: Theme.of(context).textTheme.titleMedium),
+ const Spacer(),
+ ],
+ ),
+ ),
+ const Divider(height: 1),
+ _isReloadingEssays
+ ? CircularProgressIndicator()
+ : Expanded(
+ child: ListView.separated(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.all(8),
+ itemCount: _essays.length,
+ separatorBuilder: (_, __) =>
+ const Divider(height: 1),
+ itemBuilder: (context, i) {
+ final assignment = _essays[i];
+ final selected = _selectedSidebarIndex == i;
+ final dueText = _formatDate(assignment.dueDate);
+
+ return ListTile(
+ dense: true,
+ selected: selected,
+ selectedTileColor: Theme.of(context)
+ .colorScheme
+ .primary
+ .withOpacity(0.08),
+ title: Text(assignment.name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis),
+ subtitle: Text('Due: $dueText'),
+ trailing: _statusChip(assignment),
+ onTap: () =>
+ setState(() => _selectedSidebarIndex = i),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ const VerticalDivider(width: 1),
+
+ // Right Pane: Essay Details + Reflection
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: selectedEssay == null
+ ? const Center(
+ child: Text(
+ "Select an assignment to begin reflection.",
+ style: TextStyle(color: Colors.grey),
+ ),
+ )
+ : SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Assignment: ${selectedEssay.name}',
+ style: Theme.of(context).textTheme.titleLarge),
+ const SizedBox(height: 10),
+ Text('Course: ${selectedEssay.courseId}',
+ style: Theme.of(context).textTheme.bodyLarge),
+ const SizedBox(height: 10),
+ Text('Due: ${_formatDate(selectedEssay.dueDate)}',
+ style: Theme.of(context).textTheme.bodyLarge),
+ const SizedBox(height: 10),
+ const Text('Description:',
+ style: TextStyle(fontWeight: FontWeight.bold)),
+ Html(
+ data: selectedEssay.description,
+ style: {
+ "body": Style(
+ margin: Margins.zero,
+ padding: HtmlPaddings.zero,
+ ),
+ },
+ ),
+ const SizedBox(height: 20),
+ _buildReflectionSection(selectedEssay),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildReflectionSection(Assignment essay) {
+ final id = essay.id.toString();
+ final status = _essayStatus[id] ?? ReflectionStatus.notStarted;
+
+ if (status == ReflectionStatus.notStarted) {
+ return ElevatedButton(
+ onPressed: () => _startReflection(essay),
+ child: const Text('Start Reflection'),
+ );
+ } else if (status == ReflectionStatus.inProgress) {
+ final controllers = _controllers[id]!;
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Text('Reflection Questions:',
+ style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
+ const SizedBox(height: 12),
+ ...controllers.keys.map((q) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(q.question,
+ style: const TextStyle(
+ fontWeight: FontWeight.w600, fontSize: 16)),
+ const SizedBox(height: 6),
+ TextField(
+ controller: controllers[q],
+ maxLines: 3,
+ decoration: InputDecoration(
+ hintText: 'Enter your response...',
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(10),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }),
+ const SizedBox(height: 16),
+ Center(
+ child: ElevatedButton(
+ onPressed: () => _submitReflection(essay),
+ style: ElevatedButton.styleFrom(
+ backgroundColor: Colors.blueAccent,
+ padding:
+ const EdgeInsets.symmetric(horizontal: 40, vertical: 14),
+ ),
+ child: const Text(
+ 'Submit Reflection',
+ style: TextStyle(color: Colors.white, fontSize: 16),
+ ),
+ ),
+ ),
+ ],
+ );
+ } else {
+ return const Padding(
+ padding: EdgeInsets.only(top: 12),
+ child: Text('Reflection submitted!',
+ style: TextStyle(color: Colors.green, fontWeight: FontWeight.bold)),
+ );
+ }
+ }
+}
+
+// Helper to fetch all essays from LMS
+Future> getAllEssays(int? courseID) async {
+ List result = [];
+ for (Course c in LmsFactory.getLmsService().courses ?? []) {
+ if (courseID == 0 || courseID == null || c.id == courseID) {
+ result.addAll(c.essays ?? []);
+ }
+ }
+ return result;
+}
diff --git a/LearningLens2025/frontend/lib/Views/user_settings.dart b/LearningLens2025/frontend/lib/Views/user_settings.dart
index 2e9bb573..09ff19b6 100644
--- a/LearningLens2025/frontend/lib/Views/user_settings.dart
+++ b/LearningLens2025/frontend/lib/Views/user_settings.dart
@@ -471,30 +471,37 @@ class UserSettingsState extends State {
messageColor = Colors.green;
icon = Icons.check_circle;
message =
- 'Your system meets the recommended hardware requirements for running a local large language model (LLM). '
- 'A discrete GPU with at least 8 GB of VRAM has been detected, providing acceptable performance for 7B models, which are recommended for both efficiency and accuracy. '
- 'As a general guideline, each 1 billion (1B) model parameters typically requires about 1 GB of VRAM. Your GPU may not be optimal for models larger than 7B, so please ensure your system has enough VRAM before attempting to run larger models. '
- 'Ensure your graphics drivers are up to date and that sufficient resources are available for optimal operation. '
- 'For the best performance and accuracy, using an API-hosted LLM is still recommended.';
+ '''Your system meets the recommended hardware requirements for running a Local Large Language model (LLM).
+A discrete GPU with at least 8 GB of VRAM has been detected, providing acceptable performance for 7B models - the minimum recommended size for using the local LLM function.
+
+As a general guideline, each 1 billion (1B) model parameters typically requires about 1 GB of VRAM.
+For every 1 GB of VRAM that is unavailable, an additional 2 GB of system memory (RAM) is recommended to compensate.
+
+Your GPU may not be optimal for models larger than 7B, so please ensure your system has enough memory before attempting to run larger models.
+Ensure your graphics drivers are up to date and that sufficient resources are available for optimal operation.
+
+For the best performance and accuracy, using and external LLM is still recommended.''';
} else {
messageColor = Colors.orange;
icon = Icons.warning;
message =
- 'A discrete GPU has been detected; however, the available VRAM appears to be below 8 GB. '
- 'Running larger models locally may result in slow performance, instability, or loading failures. '
- 'As a general guideline, each 1 billion (1B) model parameter typically requires about 1 GB of VRAM. '
- 'Smaller models may still operate, but responsiveness and quality may be limited. '
- 'For the best performance and accuracy, using an API-hosted LLM is recommended.';
+ '''A discrete GPU has been detected; however, the available VRAM appears to be below 8 GB.
+ Running larger models locally may result in slow performance, instability, or loading failures.
+ As a general guideline, each 1 billion (1B) model parameter typically requires about 1 GB of VRAM.
+ For every 1 GB of VRAM that is unavailable, an additional 2 GB of system memory (RAM) is recommended to compensate.
+
+ Smaller models may still operate, but responsiveness and quality may be limited. Please ensure your system has enough memory before attempting to run larger models.'
+ For the best performance and accuracy, using an external LLM is recommended.''';
}
} else {
messageColor = Colors.redAccent;
icon = Icons.dangerous;
message =
- 'Warning: No discrete GPU was detected, or GPU information is unavailable. '
- 'Running a local large language model (LLM) on integrated graphics or low-memory systems is not recommended. '
- 'This configuration may lead to severe lag, instability, or complete failure to load models. '
- 'Small models (1B) may still operate, but responsiveness and quality may be limited. '
- 'For the best performance and accuracy, using an API-hosted LLM is strongly recommended.';
+ '''Warning: No discrete GPU was detected, or GPU information is unavailable.
+ Running a local large language model (LLM) on integrated graphics or low-memory systems is not recommended.
+ This configuration may lead to severe lag, instability, or complete failure of the application.
+ Small models (1B) may still operate, but responsiveness and quality may be limited.
+ For the best performance and accuracy, using an external LLM is strongly recommended.''';
}
return Padding(
@@ -548,6 +555,97 @@ class UserSettingsState extends State {
);
}
+ void showModelDetails(BuildContext context) {
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: const Text('Information about different local models'),
+ content: SingleChildScrollView(
+ child: RichText(
+ text: const TextSpan(
+ style: TextStyle(color: Colors.black, fontSize: 15, height: 1.5),
+ children: [
+ TextSpan(
+ text:
+ 'There are many different local large language models (LLMs) you can run on your own device, each designed for specific strengths.\n'
+ 'Some excel at reasoning and analysis, while others are better for conversation, structured tasks, or coding.\n'
+ 'For EduLense application, we recommend using reasoning models for their accuracy and structured thought.\n\n'
+ 'Here’s a quick overview of the main types and when to use each:\n\n',
+ ),
+
+ // --- Reasoning Models ---
+ TextSpan(
+ text: 'Reasoning Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Math, logic, and multi-step reasoning.\nExamples: DeepSeek R1/Qwen, Qwen-Math, Llama 3-Reasoning.\nUse when: You need accuracy and structured thought.\n\n',
+ ),
+
+ // --- Balanced / General Models ---
+ TextSpan(
+ text: 'Balanced / General Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Everyday writing, summarizing, or general chat.\nExamples: Qwen 2.5, DeepSeek-Chat, Llama 3.\nUse when: You want good all-around performance and natural tone.\n\n',
+ ),
+
+ // --- Chat Models ---
+ TextSpan(
+ text: 'Chat Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Conversations, tutoring, and dialogue.\nExamples: Qwen-Chat, Mistral-Chat, Phi-3.\nUse when: You need fast, fluent, human-like responses.\n\n',
+ ),
+
+ // --- Instruction Models ---
+ TextSpan(
+ text: 'Instruction Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Structured or formatted outputs (e.g., JSON, XML, Markdown).\nExamples: Llama 3-Instruct, Qwen-Instruct, DeepSeek-Instruct.\nUse when: Tasks require the model to follow directions exactly.\n\n',
+ ),
+
+ // --- Coding Models ---
+ TextSpan(
+ text: 'Coding Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Code generation, debugging, and technical explanations.\nExamples: DeepSeek-Coder, CodeLlama, Qwen-Coder.\nUse when: Working in programming or automation tasks.\n\n',
+ ),
+
+ // --- Lightweight Models ---
+ TextSpan(
+ text: 'Lightweight Models\n',
+ style: TextStyle(fontWeight: FontWeight.bold),
+ ),
+ TextSpan(
+ text:
+ 'Best for: Fast responses on limited hardware.\nExamples: Phi-3-mini, TinyLlama, Mistral 7B.\nUse when: You prioritize speed or have less GPU memory.\n',
+ ),
+ ],
+ ),
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+ }
+
// Updated GGUF Model Picker
Widget _buildGGUFModelPicker() {
// support for web:
@@ -569,7 +667,6 @@ class UserSettingsState extends State {
child: CircularProgressIndicator(),
);
}
-
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -583,14 +680,14 @@ class UserSettingsState extends State {
'''
⚠️ Hardware & Model Requirements
-This app requires a reasoning model with at least 7B parameters to reliably generate structured content (e.g., JSON, XML) for platforms like Moodle and Google Classroom.
+EduLense recommends using a reasoning model with at least 7B parameters to reliably generate structured content (e.g., JSON, XML) for platforms like Moodle and Google Classroom.
Only GGUF models are currently supported.
-Running such models locally demands high-end hardware:
-• A discrete GPU with at least 8GB or more VRAM is recommended to run 7B models.
-• Systems using integrated graphics or low-memory setups may experience severe lag, crashes, or complete failure to load.
+Recommended hardware specifications for running local LLMs are:
+• A discrete GPU with at least 8GB or more VRAM
+• 12GB or higher system memory (RAM)
-Using the local LLM without the recommended model and hardware may result in unreliable output or total failure.
+Systems using integrated graphics or low-memory setups may experience severe lag, crashes, or complete failure to load.
For the best performance and accuracy, using an API-hosted LLM is recommended.
Please refer to the information below to better understand your device's GPU and memory specifications.
@@ -625,6 +722,21 @@ Please refer to the information below to better understand your device's GPU and
'Load Local LLM:',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 1.0),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: TextButton.icon(
+ icon: const Icon(Icons.info_outline),
+ label: const Text(
+ 'Information about different types of local models'),
+ onPressed: () => showModelDetails(context),
+ style: TextButton.styleFrom(
+ padding: const EdgeInsets.symmetric(horizontal: 1.0),
+ ),
+ ),
+ ),
+ ),
Row(
children: [
// Dropdown with checkmark
diff --git a/LearningLens2025/frontend/lib/Views/view_reflection_page.dart b/LearningLens2025/frontend/lib/Views/view_reflection_page.dart
index 30233e82..4bca91b3 100644
--- a/LearningLens2025/frontend/lib/Views/view_reflection_page.dart
+++ b/LearningLens2025/frontend/lib/Views/view_reflection_page.dart
@@ -5,29 +5,17 @@ import 'package:learninglens_app/beans/submission.dart';
class ViewReflectionPage extends StatelessWidget {
final Participant participant;
final Submission submission;
+ final List> reflections;
- const ViewReflectionPage({
- Key? key,
- required this.participant,
- required this.submission,
- }) : super(key: key);
+ const ViewReflectionPage(
+ {super.key,
+ required this.participant,
+ required this.submission,
+ required this.reflections});
@override
Widget build(BuildContext context) {
// Example reflection data
- final Map reflectionData = {
- 'How did you approach this task before using AI support?':
- 'I started by outlining the main points I wanted to cover before consulting AI tools.',
- 'In what ways did AI assistance influence your thought process or decisions?':
- 'AI helped me rephrase my arguments more clearly and provided feedback on structure.',
- 'What challenges did you face while completing this task?':
- 'It was difficult balancing my own ideas with AI suggestions without losing authenticity.',
- 'How confident are you in your final submission and why?':
- 'Fairly confident. I double-checked all content and ensured originality.',
- 'What would you do differently next time to improve your work?':
- 'Spend more time planning before using AI to ensure I stay in control of my ideas.'
- };
-
return Scaffold(
appBar: AppBar(
title: Text('Reflection for ${participant.fullname}'),
@@ -35,14 +23,14 @@ class ViewReflectionPage extends StatelessWidget {
body: Padding(
padding: const EdgeInsets.all(16.0),
child: ListView(
- children: reflectionData.entries.map((entry) {
+ children: reflections.map((entry) {
return Padding(
padding: const EdgeInsets.only(bottom: 20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- entry.key,
+ entry[0],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
@@ -51,7 +39,7 @@ class ViewReflectionPage extends StatelessWidget {
),
const SizedBox(height: 8),
TextField(
- controller: TextEditingController(text: entry.value),
+ controller: TextEditingController(text: entry[1]),
readOnly: true,
minLines: 3,
maxLines: 6,
diff --git a/LearningLens2025/frontend/lib/Views/view_submission_detail.dart b/LearningLens2025/frontend/lib/Views/view_submission_detail.dart
index 1354a806..2ca8b82b 100644
--- a/LearningLens2025/frontend/lib/Views/view_submission_detail.dart
+++ b/LearningLens2025/frontend/lib/Views/view_submission_detail.dart
@@ -9,6 +9,7 @@ import 'dart:math';
import 'package:learninglens_app/Views/view_reflection_page.dart';
import 'package:learninglens_app/beans/submission_with_grade.dart';
+import 'package:learninglens_app/services/reflection_service.dart';
class SubmissionDetail extends StatefulWidget {
final Participant participant;
@@ -33,7 +34,9 @@ class SubmissionDetailState extends State {
Map remarks = {}; // Map to store remarks
Map remarkControllers =
{}; // Controllers for each remark
+ double? calculatedGrade;
+ // List to store reflections
@override
void initState() {
super.initState();
@@ -64,6 +67,7 @@ class SubmissionDetailState extends State {
TextEditingController(text: remarks[score['criterionid']]);
}
isLoading = false;
+ calculatedGrade = computeGradeFromSelections();
});
if (fetchedRubric == null) {
@@ -79,6 +83,49 @@ class SubmissionDetailState extends State {
}
}
+ double? computeGradeFromSelections() {
+ if (rubric == null || rubric!.criteria.isEmpty) {
+ return null;
+ }
+
+ double totalAchieved = 0;
+ double totalPossible = 0;
+
+ for (final criterion in rubric!.criteria) {
+ if (criterion.levels.isEmpty) continue;
+
+ final maxScore = criterion.levels.last.score.toDouble();
+ totalPossible += maxScore;
+
+ final selectedLevelId = selectedLevels[criterion.id];
+ if (selectedLevelId == null) continue;
+
+ final matchingLevels =
+ criterion.levels.where((level) => level.id == selectedLevelId);
+ if (matchingLevels.isEmpty) continue;
+
+ totalAchieved += matchingLevels.first.score.toDouble();
+ }
+
+ if (totalPossible == 0) {
+ return null;
+ }
+
+ return (totalAchieved / totalPossible) * 100;
+ }
+
+ Future>> fetchReflections() async {
+ List> reflectionsToReturn = [];
+ final reflections = await ReflectionService().getReflectionsForAssignment(
+ int.parse(widget.courseId), widget.submission.submission.assignmentId);
+ for (Reflection r in reflections) {
+ final resp = await ReflectionService()
+ .getReflectionForSubmission(r.uuid!, widget.participant.id);
+ reflectionsToReturn.add([r.question, resp?.response ?? ""]);
+ }
+ return reflectionsToReturn;
+ }
+
// Save updated submission scores and remarks as JSON
void saveSubmissionScores() async {
List> updatedScores = [];
@@ -189,7 +236,7 @@ class SubmissionDetailState extends State {
SizedBox(height: 8),
widget.submission.submission.onlineText.isNotEmpty
? Text(
- 'Grade: ${widget.submission.grade != null ? widget.submission.grade!.grade.toString() : "Not graded yet"}',
+ 'Grade: ${calculatedGrade != null ? '${calculatedGrade!.round()}%' : widget.submission.grade != null ? widget.submission.grade!.grade.toString() : "Not graded yet"}',
style: TextStyle(fontSize: 16),
)
: Text(
@@ -210,17 +257,21 @@ class SubmissionDetailState extends State {
fontSize: 18, fontWeight: FontWeight.bold),
),
ElevatedButton.icon(
- onPressed: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => ViewReflectionPage(
- participant: widget.participant,
- submission:
- widget.submission.submission,
- ),
- ),
- );
+ onPressed: () async {
+ fetchReflections()
+ .then((value) => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) =>
+ ViewReflectionPage(
+ participant:
+ widget.participant,
+ submission: widget
+ .submission
+ .submission,
+ reflections: value),
+ ),
+ ));
},
icon: Icon(Icons.note_alt_outlined),
label: Text('View Reflection'),
@@ -379,6 +430,7 @@ class SubmissionDetailState extends State {
onTap: () {
setState(() {
selectedLevels[criterion.id] = level.id;
+ calculatedGrade = computeGradeFromSelections();
});
},
child: Container(
diff --git a/LearningLens2025/frontend/lib/Views/view_submissions.dart b/LearningLens2025/frontend/lib/Views/view_submissions.dart
index 6b0a4037..8a6b9057 100644
--- a/LearningLens2025/frontend/lib/Views/view_submissions.dart
+++ b/LearningLens2025/frontend/lib/Views/view_submissions.dart
@@ -15,6 +15,8 @@ import '../Api/llm/perplexity_api.dart';
import 'dart:convert';
import 'package:intl/intl.dart';
import 'package:learninglens_app/services/prompt_builder_service.dart';
+import 'package:learninglens_app/Api/llm/local_llm_service.dart'; // local llm
+import 'package:flutter/foundation.dart';
class SubmissionList extends StatefulWidget {
final int assignmentId;
@@ -38,6 +40,7 @@ class SubmissionListState extends State {
Map voiceSelectionMap = {};
Map detailLevelSelectionMap = {};
Map gradeLevelSelectionMap = {};
+ bool _localLlmAvail = !kIsWeb;
late Future> futureSubmissionsWithGrades =
api.getSubmissionsWithGrades(widget.assignmentId);
@@ -95,6 +98,7 @@ class SubmissionListState extends State {
final grokApiKey = LocalStorageService.getGrokKey();
final deepseekApiKey = LocalStorageService.getDeepseekKey();
final perplexityApiKey = LocalStorageService.getPerplexityKey();
+ final localLLMPath = LocalStorageService.getLocalLLMPath();
if (openApiKey.isNotEmpty) {
return LlmType.CHATGPT;
@@ -104,6 +108,8 @@ class SubmissionListState extends State {
return LlmType.DEEPSEEK;
} else if (perplexityApiKey.isNotEmpty) {
return LlmType.PERPLEXITY;
+ } else if (localLLMPath != "") {
+ return LlmType.LOCAL;
} else {
// fallback if none are available
return LlmType.CHATGPT;
@@ -463,15 +469,22 @@ class SubmissionListState extends State {
return DropdownMenuItem<
LlmType>(
value: llm,
- enabled: LocalStorageService
- .userHasLlmKey(
- llm),
+ enabled: (llm ==
+ LlmType
+ .LOCAL &&
+ LocalStorageService.getLocalLLMPath() !=
+ "" &&
+ _localLlmAvail) ||
+ LocalStorageService
+ .userHasLlmKey(
+ llm),
child: Text(
llm.displayName,
style:
TextStyle(
- color: LocalStorageService.userHasLlmKey(
- llm)
+ color: (llm == LlmType.LOCAL && LocalStorageService.getLocalLLMPath() != "" && _localLlmAvail) ||
+ LocalStorageService.userHasLlmKey(
+ llm)
? Colors
.black87
: Colors
@@ -481,6 +494,20 @@ class SubmissionListState extends State {
);
}).toList(),
),
+ if (selectedLLM ==
+ LlmType
+ .LOCAL) ...[
+ const SizedBox(
+ height: 6),
+ const Text(
+ "Running a Large Language Model (LLM) requires substantial hardware resources. The recommended model for is 7B or higher reasoning (Qwen) models. Using smaller models may produce inaccurate or misleading responses.\nFor best results, we recommend using the external LLM.\nPlease use the local LLM responsibly and independently verify any critical information.",
+ style: TextStyle(
+ fontSize: 13,
+ color: Colors
+ .black54,
+ ),
+ ),
+ ],
SizedBox(height: 4),
// Tone Dropdown
DropdownButtonFormField<
@@ -639,127 +666,154 @@ class SubmissionListState extends State {
if (submissionWithGrade !=
null)
isLoading
- ? CircularProgressIndicator()
+ ? Stack(
+ alignment:
+ Alignment
+ .center,
+ children: [
+ const CircularProgressIndicator(),
+ if (selectedLLM ==
+ LlmType
+ .LOCAL)
+ TextButton(
+ onPressed:
+ () async {
+ final decision =
+ await LocalLLMService().showCancelConfirmationDialog();
+ if (decision) {
+ LocalLLMService().cancel();
+ }
+ },
+ style: TextButton
+ .styleFrom(
+ foregroundColor:
+ Colors.redAccent,
+ ),
+ child:
+ const Text(
+ 'Cancel Generation',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500),
+ ),
+ ),
+ ],
+ )
: ElevatedButton(
onPressed:
() async {
- try {
- setState(
- () {
- isLoadingMap[participant.id] =
- true;
- });
-
- var submissionText = submissionWithGrade
- .submission
- .onlineText;
- int? contextId = await LmsFactory.getLmsService().getContextId(
- widget
- .assignmentId,
- widget
- .courseId);
-
- String?
- fetchedRubric;
- if (contextId !=
- null) {
- MoodleRubric?
- moodleRubric =
- await LmsFactory.getLmsService().getRubric(widget.assignmentId.toString());
- if (moodleRubric ==
+ if (await LocalLLMService()
+ .checkIfLoadedLocalLLMRecommended()) {
+ try {
+ setState(
+ () {
+ isLoadingMap[participant.id] =
+ true;
+ });
+
+ var submissionText = submissionWithGrade
+ .submission
+ .onlineText;
+ int? contextId = await LmsFactory.getLmsService().getContextId(
+ widget.assignmentId,
+ widget.courseId);
+
+ String?
+ fetchedRubric;
+ if (contextId !=
null) {
- print(
- 'Failed to fetch rubric.');
- return;
+ MoodleRubric?
+ moodleRubric =
+ await LmsFactory.getLmsService().getRubric(widget.assignmentId.toString());
+ if (moodleRubric ==
+ null) {
+ print('Failed to fetch rubric.');
+ return;
+ }
+ fetchedRubric =
+ jsonEncode(moodleRubric.toJson());
}
- fetchedRubric =
- jsonEncode(moodleRubric.toJson());
- }
- String queryPrompt = buildLlmPrompt(
- submissionText:
- submissionText,
- fetchedRubric:
- fetchedRubric,
- tone: toneSelectionMap[participant.id] ??
- 'Formal',
- voice: voiceSelectionMap[participant.id] ??
- 'Supportive',
- detailLevel: detailLevelSelectionMap[participant.id] ??
- 'Neutral',
- gradeLevel:
- gradeLevelSelectionMap[participant.id] ?? LearningLensConstants.gradeLevels.last);
-
- String
- apiKey =
- getApiKey(
- selectedLLM);
- dynamic
- llmInstance;
- if (selectedLLM ==
- LlmType
- .CHATGPT) {
- llmInstance =
- OpenAiLLM(apiKey);
- } else if (selectedLLM ==
- LlmType
- .GROK) {
- llmInstance =
- GrokLLM(apiKey);
- } else if (selectedLLM ==
- LlmType
- .DEEPSEEK) {
- llmInstance =
- DeepseekLLM(apiKey);
- } else {
- llmInstance =
- PerplexityLLM(apiKey);
- }
- dynamic
- gradedResponse =
- await llmInstance
- .postToLlm(queryPrompt);
- gradedResponse = gradedResponse
- .replaceAll(
- '```json',
- '')
- .replaceAll(
- '```',
- '')
- .trim();
- var results = await LmsFactory.getLmsService().setRubricGrades(
- widget
- .assignmentId,
- participant
- .id,
- gradedResponse);
- _fetchData();
- Navigator
- .push(
- context,
- MaterialPageRoute(
- builder: (context) =>
- SubmissionDetail(
- participant:
- participant,
- submission:
- submissionWithGrade,
- courseId:
- widget.courseId,
+ String queryPrompt = buildLlmPrompt(
+ submissionText:
+ submissionText,
+ fetchedRubric:
+ fetchedRubric,
+ tone: toneSelectionMap[participant.id] ??
+ 'Formal',
+ voice: voiceSelectionMap[participant.id] ??
+ 'Supportive',
+ detailLevel: detailLevelSelectionMap[participant.id] ??
+ 'Neutral',
+ gradeLevel:
+ gradeLevelSelectionMap[participant.id] ?? LearningLensConstants.gradeLevels.last);
+
+ String
+ apiKey =
+ getApiKey(selectedLLM);
+ dynamic
+ llmInstance;
+ if (selectedLLM ==
+ LlmType
+ .CHATGPT) {
+ llmInstance =
+ OpenAiLLM(apiKey);
+ } else if (selectedLLM ==
+ LlmType
+ .GROK) {
+ llmInstance =
+ GrokLLM(apiKey);
+ } else if (selectedLLM ==
+ LlmType
+ .DEEPSEEK) {
+ llmInstance =
+ DeepseekLLM(apiKey);
+ } else if (selectedLLM ==
+ LlmType.LOCAL) {
+ llmInstance =
+ LocalLLMService();
+ } else {
+ llmInstance =
+ PerplexityLLM(apiKey);
+ }
+ dynamic
+ gradedResponse =
+ await llmInstance.postToLlm(queryPrompt);
+ gradedResponse = gradedResponse
+ .replaceAll('```json',
+ '')
+ .replaceAll('```',
+ '')
+ .trim();
+ var results = await LmsFactory.getLmsService().setRubricGrades(
+ widget.assignmentId,
+ participant.id,
+ gradedResponse);
+ _fetchData();
+ Navigator
+ .push(
+ context,
+ MaterialPageRoute(
+ builder: (context) =>
+ SubmissionDetail(
+ participant: participant,
+ submission: submissionWithGrade,
+ courseId: widget.courseId,
+ ),
),
- ),
- );
- print(
- 'Results: $results');
- } catch (e) {
- print(
- 'An error occurred: $e');
- } finally {
- setState(
- () {
- isLoadingMap[participant.id] =
- false;
- });
+ );
+ print(
+ 'Results: $results');
+ } catch (e) {
+ print(
+ 'An error occurred: $e');
+ } finally {
+ setState(
+ () {
+ isLoadingMap[participant.id] =
+ false;
+ });
+ }
}
},
child: Text(
diff --git a/LearningLens2025/frontend/lib/beans/ai_log.dart b/LearningLens2025/frontend/lib/beans/ai_log.dart
index 509afa05..bca64cdc 100644
--- a/LearningLens2025/frontend/lib/beans/ai_log.dart
+++ b/LearningLens2025/frontend/lib/beans/ai_log.dart
@@ -69,6 +69,13 @@ class AiLog {
}
}
+ static bool isMarkdown(int column) {
+ if (column == 4 || column == 5) {
+ return true;
+ }
+ return false;
+ }
+
String getStringForColumn(int column) {
if (column == 7) {
return DateFormat.yMd().add_jms().format(created.toLocal());
diff --git a/LearningLens2025/frontend/lib/beans/assignment.dart b/LearningLens2025/frontend/lib/beans/assignment.dart
index 1ee20924..f763de31 100644
--- a/LearningLens2025/frontend/lib/beans/assignment.dart
+++ b/LearningLens2025/frontend/lib/beans/assignment.dart
@@ -15,6 +15,9 @@ class Assignment implements LearningLensInterface {
final List? submissionsWithGrades;
+ List individualStudentsOptions = [];
+ int? maxScore;
+
Assignment({
required this.id,
required this.name,
@@ -67,7 +70,7 @@ class Assignment implements LearningLensInterface {
@override
Assignment fromGoogleJson(Map json) {
- return Assignment(
+ Assignment a = Assignment(
id: int.parse(json['id']),
name: json['title'] ?? 'Untitled',
description: json['description'] ?? '',
@@ -87,6 +90,13 @@ class Assignment implements LearningLensInterface {
gradingStatus: 0, // TODO: figure out grading status
courseId: int.parse(json['courseId']),
);
+ if (json['AssigneeMode']?.toString() == "INDIVIDUAL_STUDENTS") {
+ final studentOptions = json["studentIds"] as List;
+ a.individualStudentsOptions
+ .addAll(studentOptions.map((e) => int.parse(e.toString())));
+ }
+ a.maxScore = json["maxPoints"];
+ return a;
}
@override
diff --git a/LearningLens2025/frontend/lib/beans/level.dart b/LearningLens2025/frontend/lib/beans/level.dart
index a72b94b0..88384449 100644
--- a/LearningLens2025/frontend/lib/beans/level.dart
+++ b/LearningLens2025/frontend/lib/beans/level.dart
@@ -24,8 +24,20 @@ class Level implements LearningLensInterface {
@override
Level fromGoogleJson(Map json) {
- // TODO: Dinesh, try to map the Google JSON to the Level object
- throw UnimplementedError();
+ final rawId = json['levelId'];
+ final rawPoints = json['points'];
+
+ return Level(
+ id: rawId is int
+ ? rawId
+ : rawId is String
+ ? rawId.hashCode
+ : 0,
+ description: (json['description'] as String?)?.trim().isNotEmpty == true
+ ? (json['description'] as String).trim()
+ : ((json['title'] as String?) ?? '').trim(),
+ score: rawPoints is num ? rawPoints.round() : 0,
+ );
}
Map toJson() {
diff --git a/LearningLens2025/frontend/lib/beans/moodle_rubric.dart b/LearningLens2025/frontend/lib/beans/moodle_rubric.dart
index 4604f681..0e688c74 100644
--- a/LearningLens2025/frontend/lib/beans/moodle_rubric.dart
+++ b/LearningLens2025/frontend/lib/beans/moodle_rubric.dart
@@ -1,5 +1,6 @@
import 'package:learninglens_app/beans/learning_lens_interface.dart';
import 'package:learninglens_app/beans/moodle_rubric_criteria.dart';
+import 'package:learninglens_app/beans/level.dart';
class MoodleRubric implements LearningLensInterface {
final String title;
@@ -26,8 +27,38 @@ class MoodleRubric implements LearningLensInterface {
@override
MoodleRubric fromGoogleJson(Map json) {
- // TODO: Dinesh, try to map the Google JSON to the MoodleRubric object and maybe change this class to be more generic
- throw UnimplementedError();
+ final criteriaJson = json['criteria'] as List? ?? const [];
+ final criteria = [];
+
+ for (var i = 0; i < criteriaJson.length; i++) {
+ final criterionJson = criteriaJson[i] as Map;
+ final levelsJson = criterionJson['levels'] as List? ?? const [];
+
+ final levels = [];
+ for (var j = 0; j < levelsJson.length; j++) {
+ final levelMap = levelsJson[j] as Map;
+ levels.add(Level.empty().fromGoogleJson(levelMap));
+ }
+
+ final rawCriterionId = criterionJson['criterionId'];
+ criteria.add(
+ MoodleRubricCriteria(
+ id: rawCriterionId is int
+ ? rawCriterionId
+ : rawCriterionId is String
+ ? rawCriterionId.hashCode
+ : i,
+ description:
+ (criterionJson['description'] as String?) ?? 'Criterion ${i + 1}',
+ levels: levels,
+ ),
+ );
+ }
+
+ return MoodleRubric(
+ title: json['title'] as String? ?? json['name'] as String? ?? 'Rubric',
+ criteria: criteria,
+ );
}
Map toJson() {
diff --git a/LearningLens2025/frontend/lib/beans/quiz.dart b/LearningLens2025/frontend/lib/beans/quiz.dart
index 95e69620..92aff8a1 100644
--- a/LearningLens2025/frontend/lib/beans/quiz.dart
+++ b/LearningLens2025/frontend/lib/beans/quiz.dart
@@ -13,6 +13,8 @@ class Quiz {
DateTime? timeOpen;
DateTime? timeClose;
+
+ List individualStudentsOptions = [];
// Constructor with all optional params.
Quiz(
{this.name,
@@ -100,6 +102,11 @@ class Quiz {
print('Debug: DueDate parsed to: ${tmpQuiz.timeClose}');
print('Debug: Quiz object created successfully');
+ if (json['AssigneeMode']?.toString() == "INDIVIDUAL_STUDENTS") {
+ final studentOptions = json["studentIds"] as List;
+ tmpQuiz.individualStudentsOptions
+ .addAll(studentOptions.map((e) => int.parse(e.toString())));
+ }
return tmpQuiz;
}
diff --git a/LearningLens2025/frontend/lib/main.dart b/LearningLens2025/frontend/lib/main.dart
index e9264986..998c4965 100644
--- a/LearningLens2025/frontend/lib/main.dart
+++ b/LearningLens2025/frontend/lib/main.dart
@@ -8,8 +8,10 @@ import 'package:learninglens_app/Views/program_assessment_view.dart';
import 'package:learninglens_app/Views/user_settings.dart';
import 'package:learninglens_app/notifiers/login_notifier.dart';
import 'package:learninglens_app/notifiers/theme_notifier.dart';
+import 'package:learninglens_app/services/gamification_service.dart';
import 'package:learninglens_app/services/local_storage_service.dart';
import 'package:learninglens_app/services/program_assessment_service.dart';
+import 'package:learninglens_app/services/reflection_service.dart';
import 'package:provider/provider.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -30,6 +32,8 @@ void main() async {
await AILoggingSingleton().createDb();
await AILoggingSingleton().clearOldDatabaseEntries();
await ProgramAssessmentService.createDb();
+ await GamificationService.createDb();
+ await ReflectionService.createDb();
runApp(
MultiProvider(
@@ -98,7 +102,7 @@ class MyApp extends StatelessWidget {
'/user': (context) => UserSettings(),
//'/send_essay_to_moodle': (context) => EssayAssignmentSettings(''),
'/assessments': (context) => AssessmentsView(),
- // '/viewExams': (context) => const ViewExamPage(),
+ // '/viewExams': (context) => const View Exam Page(),
// '/settings': (context) => Setting(themeModeNotifier: _themeModeNotifier)
'/gamification': (context) => GamificationView(),
'/evaluate': (context) => ProgramAssessmentView()
diff --git a/LearningLens2025/frontend/lib/notifiers/login_notifier.dart b/LearningLens2025/frontend/lib/notifiers/login_notifier.dart
index 31f8d6b1..b11c9b32 100644
--- a/LearningLens2025/frontend/lib/notifiers/login_notifier.dart
+++ b/LearningLens2025/frontend/lib/notifiers/login_notifier.dart
@@ -167,6 +167,8 @@ class LoginNotifier with ChangeNotifier {
// Reset LMS
LmsFactory.getLmsService().resetLMSUserInfo();
+ LocalStorageService.clearUserId();
+
notifyListeners();
}
diff --git a/LearningLens2025/frontend/lib/services/gamification_service.dart b/LearningLens2025/frontend/lib/services/gamification_service.dart
new file mode 100644
index 00000000..12416409
--- /dev/null
+++ b/LearningLens2025/frontend/lib/services/gamification_service.dart
@@ -0,0 +1,250 @@
+import 'dart:convert';
+import 'dart:developer' as developer;
+
+import 'package:http/http.dart' as http;
+import 'package:learninglens_app/Api/lms/enum/lms_enum.dart';
+import 'package:learninglens_app/services/api_service.dart';
+import 'package:learninglens_app/services/local_storage_service.dart';
+
+enum GameType { FLASHCARD, MATCHING, QUIZ }
+
+/// Represents a program assessment job
+/// Check the handleGET method in code_eval/index.mjs for properties
+// AssignedGame model
+class AssignedGame {
+ final String? uuid;
+ final int courseId;
+ final GameType gameType;
+ final String title;
+ final String gameData;
+ final int assignedBy;
+ final DateTime assignedDate;
+ LmsType lms = LocalStorageService.getSelectedClassroom();
+ AssignedGameScore? score;
+
+ AssignedGame(
+ {required this.courseId,
+ required this.gameType,
+ required this.title,
+ required this.gameData,
+ required this.assignedDate,
+ required this.assignedBy,
+ this.uuid,
+ this.score});
+}
+
+class AssignedGameScore {
+ final String? uuid;
+ final int studentId;
+ final String? studentName;
+ final int? rawCorrect;
+ final int? maxScore;
+ double? score;
+ final String game;
+
+ AssignedGameScore(
+ {required this.studentId,
+ required this.game,
+ this.studentName,
+ this.rawCorrect,
+ this.maxScore,
+ this.score,
+ this.uuid});
+}
+
+class GamificationService {
+ final gameUrl = LocalStorageService.getGameUrl();
+
+ static Uri? _buildCommandUri(
+ String baseUrl,
+ String command, {
+ Map? params,
+ }) {
+ final trimmed = baseUrl.trim();
+ if (trimmed.isEmpty) {
+ return null;
+ }
+
+ final parsed = Uri.tryParse(trimmed);
+ if (parsed == null || parsed.scheme.isEmpty) {
+ return null;
+ }
+
+ final query = {'command': command};
+ if (params != null) {
+ query.addAll(params);
+ }
+
+ final path = parsed.path.isEmpty ? '/' : parsed.path;
+ return parsed.replace(path: path, queryParameters: query);
+ }
+
+ Uri _requireUri(
+ String command, {
+ Map? params,
+ }) {
+ final uri = _buildCommandUri(gameUrl, command, params: params);
+ if (uri == null) {
+ throw StateError(
+ 'Game service URL not configured. Please verify the GAME_URL setting.',
+ );
+ }
+ return uri;
+ }
+
+ static Future createDb() async {
+ final baseUrl = LocalStorageService.getGameUrl();
+ final uri = _buildCommandUri(baseUrl, 'createDb');
+
+ if (uri == null) {
+ developer.log(
+ 'Skipping gamification database init; GAME_URL is not set or invalid.',
+ name: 'GamificationService',
+ );
+ return;
+ }
+
+ try {
+ await http.post(uri);
+ } catch (error, stackTrace) {
+ developer.log(
+ 'Failed to initialize gamification database.',
+ name: 'GamificationService',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ }
+ }
+
+ /// Starts a program assessment
+ Future createGame(AssignedGame game) async {
+ final uri = _requireUri('createGame');
+ return await ApiService().httpPost(uri,
+ body: jsonEncode({
+ 'courseId': game.courseId,
+ 'gameType': game.gameType.index,
+ 'title': game.title,
+ 'data': game.gameData,
+ 'assignedBy': game.assignedBy,
+ 'assignedDate': game.assignedDate.toString(),
+ 'lmsType': game.lms.index,
+ }));
+ }
+
+ Future assignGame(AssignedGameScore score) async {
+ final uri = _requireUri('assignGame');
+ return await ApiService().httpPost(uri,
+ body: jsonEncode({'studentId': score.studentId, 'game': score.game}));
+ }
+
+ /// Starts a program assessment
+ Future completeGame(
+ String uuid,
+ int studentId,
+ double score, {
+ int? rawCorrect,
+ int? maxScore,
+ }) async {
+ final uri = _requireUri('completeGame');
+ return await ApiService().httpPost(uri,
+ body: jsonEncode({
+ 'gameId': uuid,
+ 'studentId': studentId,
+ 'score': score,
+ 'rawCorrect': rawCorrect,
+ 'maxScore': maxScore,
+ }));
+ }
+
+ /// Gets code evaluations for all assignments in a course
+ Future> getGamesForTeacher(int createdBy) async {
+ final uri = _requireUri(
+ 'getForTeacher',
+ params: {
+ 'createdBy': '$createdBy',
+ 'lmsType': '${LocalStorageService.getSelectedClassroom().index}'
+ },
+ );
+ final response = await ApiService().httpGet(uri);
+
+ if (response.statusCode != 200) return [];
+
+ return parseResponse(response.body);
+ }
+
+ /// Gets code evaluations for all assignments in a course
+ Future> getGamesForStudent(int assignedTo) async {
+ final uri = _requireUri(
+ 'getForStudent',
+ params: {
+ 'assignedTo': '$assignedTo',
+ 'lmsType': '${LocalStorageService.getSelectedClassroom().index}'
+ },
+ );
+ final response = await ApiService().httpGet(uri);
+
+ if (response.statusCode != 200) return [];
+
+ return parseResponse(response.body);
+ }
+
+ List parseResponse(String responseBody) {
+ final evaluations = jsonDecode(responseBody) as List;
+ return evaluations.map((eval) {
+ final rawScore = eval['score'];
+ double? parsedScore;
+ if (rawScore != null) {
+ if (rawScore is num) {
+ parsedScore = rawScore.toDouble();
+ } else if (rawScore is String && rawScore.isNotEmpty) {
+ parsedScore = double.tryParse(rawScore);
+ }
+ }
+
+ final rawData = eval['data'];
+ final gameData = rawData is String ? rawData : jsonEncode(rawData);
+
+ final rawType = eval['game_type'];
+ GameType type;
+ if (rawType is int && rawType >= 0 && rawType < GameType.values.length) {
+ type = GameType.values[rawType];
+ } else {
+ type = GameType.QUIZ;
+ }
+
+ return AssignedGame(
+ uuid: eval['game_id'],
+ courseId: int.parse(eval['course_id']),
+ gameType: type,
+ title: eval['title'],
+ gameData: gameData,
+ assignedBy: int.parse(eval['assigned_by']),
+ assignedDate: DateTime.parse(eval['assigned_date']),
+ score: AssignedGameScore(
+ studentId: int.parse(eval['student_id']),
+ studentName: eval['student_name'],
+ rawCorrect: eval['raw_correct'] == null
+ ? null
+ : int.tryParse(eval['raw_correct'].toString()),
+ game: eval['game_id'],
+ maxScore: eval['max_score'] == null
+ ? null
+ : int.tryParse(eval['max_score'].toString()),
+ score: parsedScore));
+ }).toList();
+ }
+
+ Future deleteGame(String uuid) async {
+ try {
+ final uri = _requireUri('deleteGame');
+ final response = await http.delete(uri,
+ body: jsonEncode({
+ 'gameId': uuid,
+ }));
+
+ return response.statusCode == 200;
+ } catch (ex) {
+ return false;
+ }
+ }
+}
diff --git a/LearningLens2025/frontend/lib/services/local_storage_service.dart b/LearningLens2025/frontend/lib/services/local_storage_service.dart
index e10e9cde..9232e816 100644
--- a/LearningLens2025/frontend/lib/services/local_storage_service.dart
+++ b/LearningLens2025/frontend/lib/services/local_storage_service.dart
@@ -340,12 +340,38 @@ class LocalStorageService {
return url;
}
+ static String getGameUrl() {
+ String url = _prefs.getString('GAME_URL') ?? dotenv.env['GAME_URL'] ?? '';
+ if (url.endsWith('/')) {
+ url = url.substring(0, url.length - 1);
+ }
+ return url;
+ }
+
+ static String getReflectionsUrl() {
+ String url = _prefs.getString('REFLECTIONS_URL') ??
+ dotenv.env['REFLECTIONS_URL'] ??
+ '';
+ if (url.endsWith('/')) {
+ url = url.substring(0, url.length - 1);
+ }
+ return url;
+ }
+
static void clearAILoggingUrl() {
_prefs.remove('AI_LOGGING_URL');
}
static void clearCodeEvalUrl() {
- _prefs.remove('AI_LOGGING_URL');
+ _prefs.remove('CODE_EVAL_URL');
+ }
+
+ static void clearGameUrl() {
+ _prefs.remove('GAME_URL');
+ }
+
+ static void clearReflectionsUrl() {
+ _prefs.remove('REFLECTIONS_URL');
}
static hasLLMKey() {
@@ -388,4 +414,29 @@ class LocalStorageService {
print(LocalStorageService.getSelectedClassroom());
return LocalStorageService.getSelectedClassroom() == LmsType.MOODLE;
}
+
+ /// Generic method to save a string value
+ static void setString(String key, String value) {
+ _prefs.setString(key, value);
+ }
+
+ /// Generic method to retrieve a string value
+ static String? getString(String key) {
+ return _prefs.getString(key);
+ }
+
+ /// Saves current user ID
+ static void saveUserId(String userId) {
+ _prefs.setString('userId', userId);
+ }
+
+ /// Clears current user ID
+ static void clearUserId() {
+ _prefs.remove('userId');
+ }
+
+ /// Retrieves current user ID
+ static String? getUserId() {
+ return _prefs.getString('userId');
+ }
}
diff --git a/LearningLens2025/frontend/lib/services/prompt_builder_service.dart b/LearningLens2025/frontend/lib/services/prompt_builder_service.dart
index ca434592..b5bed3a0 100644
--- a/LearningLens2025/frontend/lib/services/prompt_builder_service.dart
+++ b/LearningLens2025/frontend/lib/services/prompt_builder_service.dart
@@ -116,35 +116,44 @@ PermTokens essayAssistPromptBuilder(AiMode mode, String? submissionText,
// Base core instructions
core += '''
- You are an AI assistant designed to help students write and improve essays. Provide clear, concise, and accurate information in a friendly and approachable manner. Always aim to enhance the user's learning experience.
- Look in the description of the essay assignment for an age or grade level indication and adjust your language and suggestions accordingly. If no indication is found, assume the user is an older student (high school or college level).
- You have three main modes: Brainstorm, Outline, and Revise. As well as several helper functions.
- In Brainstorm mode, your main focus is to answer questions and provide suggestions for topics and ideas as well as to help clear up any confusion.
- In Outline mode, your main focus is to help the user create a structured outline for their essay, including main points and supporting details.
- In Revise mode, your main focus is to help the user improve their essay by providing feedback on structure, clarity, grammar, and style.
- If the user submits a prompt that could benefit from a different mode, gently suggest switching modes to better assist them.
- If the user attempts to ask for examples or for help outside of your instructions, gently remind them of your purpose and that these messages are being logged for review by their teacher.
- At the end of each response, add a 'Micro-reflection' where you ask the user to reflect on the information provided and how it can help them improve their essay. Focus on reflecting on their use of AI assistance, critical thinking, and research skills.
- When creating micro-reflection questions, consider the following guidelines:
- -If suggestions are provided, ask the user how they plan to implement them in their essay
- -If factual information is provided, ask them to verify it with credible sources.
- -If sources are provided, ask them to evaluate the credibility and relevance of those sources.
- -Ask the user to consider how they can apply what they've learned from this interaction to their future writing tasks.
- -Making challenging questions that promote deeper thinking on the topic is encouraged.
-
- Your response must follow this exact structure and include all dividers as written.
- **Mode:** [Current Mode]
- ________________________________________________
- [Main response content goes here.]
- ________________________________________________
- **[Reflection greeting]**: [Your micro-reflection question goes here.]
-
- Do not omit or change the dividers. They must appear exactly as shown (each line should contain 54 underscores)
- In place of [Reflection greeting], use one of the following:
- - “Stop and think…”
- - “Ask yourself…”
- - “Take a moment to reflect…”
- ''';
+ You are an AI assistant designed to help students plan, write, and refine their essays. Provide clear, concise, and accurate information in a friendly, approachable tone. Your goal is to enhance the user's learning experience and strengthen their writing skills.
+
+ Refer to the essay assignment description for any mention of the student’s age or grade level, and tailor your language and suggestions accordingly. If no such indication is found, assume the user is an older student (high school or college level).
+
+ You operate in three primary modes—**Brainstorm**, **Outline**, and **Revise**—along with several helper functions:
+ - **Brainstorm mode**: Focus on answering questions, generating ideas, and clarifying topics or points of confusion.
+ - **Outline mode**: Help the user organize their ideas into a structured outline with main points and supporting details.
+ - **Revise mode**: Provide feedback to improve structure, clarity, grammar, and overall writing quality.
+
+ If a user prompt would be better handled in a different mode, gently suggest switching modes to provide more effective assistance.
+
+ If the user requests examples or help outside your defined purpose, politely remind them of your role and that all messages are logged for review by their teacher.
+
+ At the end of every response, include a **Micro-reflection** section to encourage the user to think critically about how the interaction supports their learning and essay development. Micro-reflections should promote thoughtful use of AI, self-evaluation, and research skills.
+
+ When creating micro-reflection questions, follow these guidelines:
+ - If you offered writing suggestions, ask how the user plans to implement them in their essay.
+ - If you provided factual information, prompt the user to verify it using credible sources.
+ - If you suggested sources, encourage them to evaluate those sources for credibility and relevance.
+ - Ask the user how they can apply what they learned from this exchange to future writing tasks.
+ - Whenever possible, pose thought-provoking questions that promote deeper reflection on the topic.
+
+ Your response **must** follow this exact structure and include all dividers as shown:
+
+ **Mode:** [Current Mode]
+ ______________________________________________________
+ [Main response content goes here.]
+ ______________________________________________________
+ **[Reflection greeting]:** [Your micro-reflection question goes here.]
+
+ Do not change or omit the dividers — each line must contain exactly **54 underscores**.
+ Always use bullet points over numbered lists unless specifically instructed otherwise by the user.
+
+ For **[Reflection greeting]**, choose one of the following:
+ - “Stop and think…”
+ - “Ask yourself…”
+ - “Take a moment to reflect…”
+ ''';
// Mode-specific instructions
switch (mode) {
diff --git a/LearningLens2025/frontend/lib/services/reflection_service.dart b/LearningLens2025/frontend/lib/services/reflection_service.dart
new file mode 100644
index 00000000..8e66bcb1
--- /dev/null
+++ b/LearningLens2025/frontend/lib/services/reflection_service.dart
@@ -0,0 +1,183 @@
+import 'dart:convert';
+import 'dart:developer' as developer;
+
+import 'package:http/http.dart' as http;
+import 'package:learninglens_app/Api/lms/enum/lms_enum.dart';
+import 'package:learninglens_app/services/api_service.dart';
+import 'package:learninglens_app/services/local_storage_service.dart';
+
+/// Represents a program assessment job
+/// Check the handleGET method in code_eval/index.mjs for properties
+// AssignedGame model
+class Reflection {
+ final String? uuid;
+ final int courseId;
+ final int assignmentId;
+ final String question;
+ final DateTime date = DateTime.now();
+ LmsType lms = LocalStorageService.getSelectedClassroom();
+
+ Reflection(
+ {required this.courseId,
+ required this.assignmentId,
+ required this.question,
+ this.uuid});
+}
+
+class ReflectionResponse {
+ final String? uuid;
+ final String reflectionId;
+ final int studentId;
+ final String response;
+ final DateTime date = DateTime.now();
+
+ ReflectionResponse(
+ {required this.studentId,
+ required this.response,
+ required this.reflectionId,
+ this.uuid});
+}
+
+class ReflectionService {
+ final reflectionUrl = LocalStorageService.getReflectionsUrl();
+
+ static Uri? _buildCommandUri(
+ String baseUrl,
+ String command, {
+ Map? params,
+ }) {
+ final trimmed = baseUrl.trim();
+ if (trimmed.isEmpty) {
+ return null;
+ }
+
+ final parsed = Uri.tryParse(trimmed);
+ if (parsed == null || parsed.scheme.isEmpty) {
+ return null;
+ }
+
+ final query = {'command': command};
+ if (params != null) {
+ query.addAll(params);
+ }
+
+ final path = parsed.path.isEmpty ? '/' : parsed.path;
+ return parsed.replace(path: path, queryParameters: query);
+ }
+
+ Uri _requireUri(
+ String command, {
+ Map? params,
+ }) {
+ final uri = _buildCommandUri(reflectionUrl, command, params: params);
+ if (uri == null) {
+ throw StateError(
+ 'Reflection service URL not configured. Please verify the REFLECTION_URL setting.',
+ );
+ }
+ return uri;
+ }
+
+ static Future createDb() async {
+ final baseUrl = LocalStorageService.getReflectionsUrl();
+ final uri = _buildCommandUri(baseUrl, 'createDb');
+
+ if (uri == null) {
+ developer.log(
+ 'Skipping reflection database init; REFLECTION_URL is not set or invalid.',
+ name: 'ReflectionService',
+ );
+ return;
+ }
+
+ try {
+ await http.post(uri);
+ } catch (error, stackTrace) {
+ developer.log(
+ 'Failed to initialize reflection database.',
+ name: 'ReflectionService',
+ error: error,
+ stackTrace: stackTrace,
+ );
+ }
+ }
+
+ /// Starts a program assessment
+ Future createReflection(Reflection ref) async {
+ final uri = _requireUri('createReflection');
+ return await ApiService().httpPost(uri,
+ body: jsonEncode({
+ 'courseId': ref.courseId,
+ 'assignmentId': ref.assignmentId,
+ 'question': ref.question,
+ 'lmsType': ref.lms.index,
+ }));
+ }
+
+ /// Starts a program assessment
+ Future completeReflection(ReflectionResponse resp) async {
+ final uri = _requireUri('completeReflection');
+ return await ApiService().httpPost(uri,
+ body: jsonEncode({
+ 'studentId': resp.studentId,
+ 'response': resp.response,
+ 'reflectionId': resp.reflectionId
+ }));
+ }
+
+ /// Gets code evaluations for all assignments in a course
+ Future> getReflectionsForAssignment(
+ int courseId, int assignmentId) async {
+ final uri = _requireUri(
+ 'getReflection',
+ params: {
+ 'courseId': '$courseId',
+ 'assignmentId': '$assignmentId',
+ 'lmsType': '${LocalStorageService.getSelectedClassroom().index}'
+ },
+ );
+ final response = await ApiService().httpGet(uri);
+
+ if (response.statusCode != 200) return [];
+
+ final reflections = jsonDecode(response.body) as List;
+ return reflections.map((ref) {
+ return Reflection(
+ uuid: ref['reflection_id'],
+ courseId: courseId,
+ assignmentId: assignmentId,
+ question: ref['question'],
+ );
+ }).toList();
+ }
+
+ /// Gets code evaluations for all assignments in a course
+ Future getReflectionForSubmission(
+ String reflectionId, int studentId) async {
+ final uri = _requireUri(
+ 'getCompletedReflection',
+ params: {'reflectionId': reflectionId, 'studentId': '$studentId'},
+ );
+ final response = await ApiService().httpGet(uri);
+
+ if (response.statusCode != 200) return null;
+
+ final reflections = jsonDecode(response.body);
+
+ if (reflections is List) {
+ return reflections
+ .map((ref) {
+ return ReflectionResponse(
+ uuid: ref['response_id'],
+ studentId: studentId,
+ response: ref['response'],
+ reflectionId: ref['reflection'],
+ );
+ })
+ .toList()
+ .firstOrNull;
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/LearningLens2025/frontend/pubspec.yaml b/LearningLens2025/frontend/pubspec.yaml
index 46160687..dcaafb58 100644
--- a/LearningLens2025/frontend/pubspec.yaml
+++ b/LearningLens2025/frontend/pubspec.yaml
@@ -36,7 +36,6 @@ dependencies:
url_launcher: ^6.3.2
logging: ^1.3.0
syncfusion_flutter_pdf: ^24.1.41
-
flutter_quill: ^11.0.0
flutter_quill_extensions: ^11.0.0
flutter_chat_ui: ^1.6.9
@@ -49,6 +48,10 @@ dependencies:
sdk: flutter
quill_delta: ^3.0.0-nullsafety.2
vsc_quill_delta_to_html: ^1.0.5
+ flutter_html: ^3.0.0
+ super_clipboard: ^0.9.1
+ flutter_quill_delta_from_html: ^1.5.3
+ markdown: ^7.3.0
dev_dependencies:
flutter_test:
sdk: flutter
diff --git a/LearningLens2025/lambda/gettoken/index.mjs b/LearningLens2025/lambda/ai_log/index.mjs
similarity index 100%
rename from LearningLens2025/lambda/gettoken/index.mjs
rename to LearningLens2025/lambda/ai_log/index.mjs
diff --git a/LearningLens2025/lambda/gettoken/package-lock.json b/LearningLens2025/lambda/ai_log/package-lock.json
similarity index 96%
rename from LearningLens2025/lambda/gettoken/package-lock.json
rename to LearningLens2025/lambda/ai_log/package-lock.json
index c1f8ffb1..b3d2e03e 100644
--- a/LearningLens2025/lambda/gettoken/package-lock.json
+++ b/LearningLens2025/lambda/ai_log/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "gettoken",
+ "name": "ai_log",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/LearningLens2025/lambda/gettoken/package.json b/LearningLens2025/lambda/ai_log/package.json
similarity index 100%
rename from LearningLens2025/lambda/gettoken/package.json
rename to LearningLens2025/lambda/ai_log/package.json
diff --git a/LearningLens2025/lambda/game_data/index.mjs b/LearningLens2025/lambda/game_data/index.mjs
new file mode 100644
index 00000000..0cee1162
--- /dev/null
+++ b/LearningLens2025/lambda/game_data/index.mjs
@@ -0,0 +1,223 @@
+import { DsqlSigner } from "@aws-sdk/dsql-signer";
+import postgres from "postgres"
+
+export const handler = async (event, context) => {
+ const signer = new DsqlSigner({
+ hostname: process.env.AWS_DB_CLUSTER,
+ region: process.env.AWS_REGION,
+ });
+ let client;
+ try {
+ // Use `getDbConnectAuthToken` if you are _not_ logging in as the `admin` user
+ const token = await signer.getDbConnectAdminAuthToken();
+
+ client = postgres({
+ host: process.env.AWS_DB_CLUSTER,
+ user: "admin",
+ password: token,
+ database: "postgres",
+ port: 5432,
+ idle_timeout: 2,
+ ssl: {
+ rejectUnauthorized: true,
+ },
+ });
+
+ } catch (error) {
+ console.error("Failed to create connection: ", error);
+ throw error;
+ }
+ const command = event["queryStringParameters"]["command"];
+ const method = event["requestContext"]["http"]["method"];
+ console.log(command);
+
+ if (method === "GET") {
+ if (command === "getForStudent") {
+ const assignedTo = BigInt(event["queryStringParameters"]["assignedTo"]);
+ const lms = parseInt(event["queryStringParameters"]["lmsType"]);
+ return await getGamesForStudent(client, assignedTo, lms);
+ }
+ if (command === "getForTeacher") {
+ const createdBy = BigInt(event["queryStringParameters"]["createdBy"]);
+ const lms = parseInt(event["queryStringParameters"]["lmsType"]);
+ return await getGamesForTeacher(client, createdBy, lms);
+ }
+ }
+ if (method === "POST") {
+ if (command === "createDb") {
+ await buildDatabase(client);
+ return "Database created successfully.";
+ }
+ if (command === "completeGame") {
+ await completeGame(client, event["body"]);
+ return "Completed game successfully.";
+ }
+ if (command === "createGame") {
+ return createGame(client, event["body"]);
+ }
+ if (command === "assignGame") {
+ await assignGame(client, event["body"]);
+ return "Assigned game successfully";
+ }
+ }
+ if (method === "DELETE") {
+ await deleteGame(client, event["body"]);
+ return "Database created successfully.";
+ }
+};
+
+ async function buildDatabase(client) {
+ try {
+ await client`CREATE TABLE IF NOT EXISTS GAMES (
+ game_id UUID PRIMARY KEY,
+ course_id BIGINT,
+ title VARCHAR,
+ data VARCHAR,
+ game_type SMALLINT,
+ assigned_by BIGINT,
+ assigned_date TIMESTAMP,
+ lms_service SMALLINT
+ );`;
+ await client`CREATE TABLE IF NOT EXISTS GAME_SCORES (
+ score_id UUID PRIMARY KEY,
+ student_id BIGINT,
+ score NUMERIC(5,2),
+ raw_correct SMALLINT,
+ max_score SMALLINT,
+ game UUID
+ );`;
+ }
+ catch (error) {
+ console.error("Failed to create database table: ", error);
+ throw error;
+ }
+};
+
+ async function deleteGame(client, body) {
+ try {
+ let log = JSON.parse(body);
+ await client`DELETE FROM GAME_SCORES WHERE game = ${log.gameId};`;
+ await client`DELETE FROM GAMES WHERE game_id = ${log.gameId};`;
+ }
+ catch (error) {
+ console.error("Failed to delete game: ", error);
+ throw error;
+ }
+};
+
+ async function getGamesForStudent(client, studentId, lms) {
+ try {
+ return await client`SELECT game_id, course_id, student_id, title, data, game_type, score, raw_correct, max_score, assigned_by, lms_service, assigned_date FROM GAMES INNER JOIN GAME_SCORES ON game = game_id WHERE
+ student_id = ${studentId}
+ AND lms_service = ${lms};`;
+ }
+ catch (error) {
+ console.error("Failed to get games for student ", error);
+ throw error;
+ }
+ };
+
+ async function getGamesForTeacher(client, assignedBy, lms) {
+ try {
+ return await client`SELECT game_id, course_id, student_id, title, data, game_type, score, raw_correct, max_score, assigned_by, assigned_date FROM GAMES INNER JOIN GAME_SCORES ON game = game_id WHERE
+ assigned_by = ${assignedBy}
+ AND lms_service = ${lms};`;
+ }
+ catch (error) {
+ console.error("Failed to get games for teacher ", error);
+ throw error;
+ }
+ };
+
+ async function createGame(client, body) {
+ try {
+ let log = JSON.parse(body);
+ return await client`
+ INSERT INTO GAMES (
+ game_id,
+ course_id,
+ title,
+ data,
+ game_type,
+ assigned_by,
+ assigned_date,
+ lms_service
+ ) VALUES (
+ gen_random_uuid(),
+ ${log.courseId},
+ ${log.title},
+ ${log.data},
+ ${log.gameType},
+ ${log.assignedBy},
+ ${log.assignedDate},
+ ${log.lmsType}
+ ) RETURNING game_id;`;
+ }
+ catch (error) {
+ console.error("Failed to add game ", error);
+ throw error;
+ }
+ };
+
+ async function assignGame(client, body) {
+ try {
+ let log = JSON.parse(body);
+ return await client`
+ INSERT INTO GAME_SCORES (
+ score_id,
+ student_id,
+ score,
+ raw_correct,
+ max_score,
+ game
+ ) VALUES (
+ gen_random_uuid(),
+ ${log.studentId},
+ NULL,
+ NULL,
+ NULL,
+ ${log.game}
+ );`;
+ }
+ catch (error) {
+ console.error("Failed to add game score ", error);
+ throw error;
+ }
+ };
+
+ async function completeGame(client, body) {
+ try {
+ let log = JSON.parse(body);
+ let rawScore = Number(log.score);
+ if (!Number.isFinite(rawScore)) {
+ rawScore = 0;
+ }
+ const normalizedScore = Math.max(
+ 0,
+ Math.min(rawScore > 1 ? rawScore / 100 : rawScore, 1)
+ );
+
+ let rawCorrect = Number.isFinite(Number(log.rawCorrect))
+ ? Number(log.rawCorrect)
+ : null;
+ if (rawCorrect !== null) {
+ rawCorrect = Math.max(0, Math.round(rawCorrect));
+ }
+ let maxScore = Number.isFinite(Number(log.maxScore))
+ ? Number(log.maxScore)
+ : null;
+ if (maxScore !== null) {
+ maxScore = Math.max(0, Math.round(maxScore));
+ }
+ return await client`
+ UPDATE GAME_SCORES
+ SET score = ${normalizedScore},
+ raw_correct = ${rawCorrect},
+ max_score = ${maxScore}
+ WHERE game = ${log.gameId} AND student_id = ${log.studentId};`;
+ }
+ catch (error) {
+ console.error("Failed to update score ", error);
+ throw error;
+ }
+ };
diff --git a/LearningLens2025/lambda/game_data/package-lock.json b/LearningLens2025/lambda/game_data/package-lock.json
new file mode 100644
index 00000000..dea55b99
--- /dev/null
+++ b/LearningLens2025/lambda/game_data/package-lock.json
@@ -0,0 +1,25 @@
+{
+ "name": "game_data",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "postgres": "^3.4.7"
+ }
+ },
+ "node_modules/postgres": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
+ "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/porsager"
+ }
+ }
+ }
+}
diff --git a/LearningLens2025/lambda/game_data/package.json b/LearningLens2025/lambda/game_data/package.json
new file mode 100644
index 00000000..2d5ce5c6
--- /dev/null
+++ b/LearningLens2025/lambda/game_data/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "postgres": "^3.4.7"
+ }
+}
diff --git a/LearningLens2025/lambda/reflections/index.mjs b/LearningLens2025/lambda/reflections/index.mjs
new file mode 100644
index 00000000..9e7dbbf9
--- /dev/null
+++ b/LearningLens2025/lambda/reflections/index.mjs
@@ -0,0 +1,160 @@
+import { DsqlSigner } from "@aws-sdk/dsql-signer";
+import postgres from "postgres"
+
+export const handler = async (event, context) => {
+ const signer = new DsqlSigner({
+ hostname: process.env.AWS_DB_CLUSTER,
+ region: process.env.AWS_REGION,
+ });
+ let client;
+ try {
+ // Use `getDbConnectAuthToken` if you are _not_ logging in as the `admin` user
+ const token = await signer.getDbConnectAdminAuthToken();
+
+ client = postgres({
+ host: process.env.AWS_DB_CLUSTER,
+ user: "admin",
+ password: token,
+ database: "postgres",
+ port: 5432,
+ idle_timeout: 2,
+ ssl: {
+ rejectUnauthorized: true,
+ },
+ });
+
+ } catch (error) {
+ console.error("Failed to create connection: ", error);
+ throw error;
+ }
+ const command = event["queryStringParameters"]["command"];
+ const method = event["requestContext"]["http"]["method"];
+ console.log(command);
+
+ if (method === "GET") {
+ if (command === "getReflection") {
+ const courseId = BigInt(event["queryStringParameters"]["courseId"]);
+ const assignmentId = BigInt(event["queryStringParameters"]["assignmentId"]);
+ const lms = parseInt(event["queryStringParameters"]["lmsType"]);
+ return await getReflectionForAssignment(client, courseId, assignmentId, lms);
+ }
+ if (command === "getCompletedReflection") {
+ const reflectionId = event["queryStringParameters"]["reflectionId"];
+ const studentId = BigInt(event["queryStringParameters"]["studentId"]);
+ return await getReflectionForSubmission(client, reflectionId, studentId);
+ }
+ }
+ if (method === "POST") {
+ if (command === "createDb") {
+ await buildDatabase(client);
+ return "Database created successfully.";
+ }
+ if (command === "completeReflection") {
+ await completeReflection(client, event["body"]);
+ return "Completed reflection successfully.";
+ }
+ if (command === "createReflection") {
+ await createReflection(client, event["body"]);
+ return "Added reflection successfully";
+ }
+ }
+};
+
+ async function buildDatabase(client) {
+ try {
+ await client`CREATE TABLE IF NOT EXISTS REFLECTIONS (
+ reflection_id UUID PRIMARY KEY,
+ course_id BIGINT,
+ assignment_id BIGINT,
+ question VARCHAR,
+ date TIMESTAMP,
+ lms_service SMALLINT
+ );`;
+ await client`CREATE TABLE IF NOT EXISTS REFLECTION_RESPONSES (
+ response_id UUID PRIMARY KEY,
+ student_id BIGINT,
+ response VARCHAR,
+ date TIMESTAMP,
+ reflection UUID
+ );`
+ }
+ catch (error) {
+ console.error("Failed to create database table: ", error);
+ throw error;
+ }
+};
+
+ async function getReflectionForAssignment(client, courseId, assignmentId, lms) {
+ try {
+ return await client`SELECT reflection_id, question FROM REFLECTIONS WHERE
+ course_id = ${courseId} AND
+ assignment_id = ${assignmentId} AND
+ lms_service = ${lms};`;
+ }
+ catch (error) {
+ console.error("Failed to get reflections for assignment ", error);
+ throw error;
+ }
+ };
+
+ async function getReflectionForSubmission(client, reflectionId, studentId) {
+ try {
+ return await client`SELECT response_id, response, reflection FROM REFLECTION_RESPONSES WHERE
+ reflection = ${reflectionId} AND
+ student_id = ${studentId};`;
+ }
+ catch (error) {
+ console.error("Failed to get reflection for assignment ", error);
+ throw error;
+ }
+ };
+
+ async function createReflection(client, body) {
+ try {
+ let log = JSON.parse(body);
+ return await client`
+ INSERT INTO REFLECTIONS (
+ reflection_id,
+ course_id,
+ assignment_id,
+ question,
+ date,
+ lms_service
+ ) VALUES (
+ gen_random_uuid(),
+ ${log.courseId},
+ ${log.assignmentId},
+ ${log.question},
+ current_timestamp AT TIME ZONE 'UTC',
+ ${log.lmsType}
+ );`;
+ }
+ catch (error) {
+ console.error("Failed to add reflection ", error);
+ throw error;
+ }
+ };
+
+ async function completeReflection(client, body) {
+ try {
+ let log = JSON.parse(body);
+ return await client`
+ INSERT INTO REFLECTION_RESPONSES (
+ response_id,
+ student_id,
+ response,
+ date,
+ reflection
+ ) VALUES (
+ gen_random_uuid(),
+ ${log.studentId},
+ ${log.response},
+ current_timestamp AT TIME ZONE 'UTC',
+ ${log.reflectionId}
+ );`;
+ }
+ catch (error) {
+ console.error("Failed to update reflection ", error);
+ throw error;
+ }
+ };
diff --git a/LearningLens2025/lambda/reflections/package-lock.json b/LearningLens2025/lambda/reflections/package-lock.json
new file mode 100644
index 00000000..eb7c533b
--- /dev/null
+++ b/LearningLens2025/lambda/reflections/package-lock.json
@@ -0,0 +1,25 @@
+{
+ "name": "reflections",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "postgres": "^3.4.7"
+ }
+ },
+ "node_modules/postgres": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
+ "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/porsager"
+ }
+ }
+ }
+}
diff --git a/LearningLens2025/lambda/reflections/package.json b/LearningLens2025/lambda/reflections/package.json
new file mode 100644
index 00000000..2d5ce5c6
--- /dev/null
+++ b/LearningLens2025/lambda/reflections/package.json
@@ -0,0 +1,5 @@
+{
+ "dependencies": {
+ "postgres": "^3.4.7"
+ }
+}
diff --git a/LearningLens2025/terraform/main.tf b/LearningLens2025/terraform/main.tf
index 89642401..bd1aaa3d 100644
--- a/LearningLens2025/terraform/main.tf
+++ b/LearningLens2025/terraform/main.tf
@@ -200,7 +200,7 @@ resource "aws_dsql_cluster" "edulense" {
deletion_protection_enabled = true
tags = {
- Name = "EduLenseAILoggingDatabase"
+ Name = "EduLenseDatabaseCluster"
}
}
@@ -244,25 +244,25 @@ resource "aws_iam_role_policy_attachment" "logging" {
}
# Run npm install before zipping
-resource "null_resource" "npm_install_gettoken" {
+resource "null_resource" "npm_install_ai_log" {
triggers = {
# Trigger npm install when package.json or lock file changes
- package_json = filemd5("../lambda/gettoken/package.json")
- package_lock = filemd5("../lambda/gettoken/package-lock.json")
+ package_json = filemd5("../lambda/ai_log/package.json")
+ package_lock = filemd5("../lambda/ai_log/package-lock.json")
}
provisioner "local-exec" {
- command = "cd ../lambda/gettoken && npm install"
+ command = "cd ../lambda/ai_log && npm install"
}
}
-data "archive_file" "get_token" {
+data "archive_file" "ai_log" {
type = "zip"
- source_dir = "../lambda/gettoken"
- excludes = ["../lambda/gettoken/gettoken.zip"]
- output_path = "../lambda/gettoken/gettoken.zip"
+ source_dir = "../lambda/ai_log"
+ excludes = ["../lambda/ai_log/ai_log.zip"]
+ output_path = "../lambda/ai_log/ai_log.zip"
- depends_on = [null_resource.npm_install_gettoken]
+ depends_on = [null_resource.npm_install_ai_log]
}
data "archive_file" "zip_plugin" {
@@ -271,12 +271,12 @@ data "archive_file" "zip_plugin" {
output_path = "../MoodlePlugin/learninglens.zip"
}
-resource "aws_lambda_function" "get_token" {
- filename = data.archive_file.get_token.output_path
- function_name = "get_db_token"
+resource "aws_lambda_function" "ai_log" {
+ filename = data.archive_file.ai_log.output_path
+ function_name = "ai_log"
role = aws_iam_role.lambda_token.arn
handler = "index.handler"
- source_code_hash = data.archive_file.get_token.output_base64sha256
+ source_code_hash = data.archive_file.ai_log.output_base64sha256
runtime = "nodejs20.x"
timeout = "10"
environment {
@@ -288,8 +288,8 @@ resource "aws_lambda_function" "get_token" {
}
}
-resource "aws_lambda_function_url" "get_token_url" {
- function_name = aws_lambda_function.get_token.function_name
+resource "aws_lambda_function_url" "get_ai_log_url" {
+ function_name = aws_lambda_function.ai_log.function_name
authorization_type = "NONE"
cors {
allow_methods = ["GET", "POST"]
@@ -297,3 +297,101 @@ resource "aws_lambda_function_url" "get_token_url" {
allow_headers = ["content-type"]
}
}
+
+# Run npm install before zipping
+resource "null_resource" "npm_install_game_data" {
+ triggers = {
+ # Trigger npm install when package.json or lock file changes
+ package_json = filemd5("../lambda/game_data/package.json")
+ package_lock = filemd5("../lambda/game_data/package-lock.json")
+ }
+
+ provisioner "local-exec" {
+ command = "cd ../lambda/game_data && npm install"
+ }
+}
+
+data "archive_file" "game_data" {
+ type = "zip"
+ source_dir = "../lambda/game_data"
+ excludes = ["../lambda/game_data/game_data.zip"]
+ output_path = "../lambda/game_data/game_data.zip"
+
+ depends_on = [null_resource.npm_install_game_data]
+}
+
+resource "aws_lambda_function" "game_data" {
+ filename = data.archive_file.game_data.output_path
+ function_name = "game_data"
+ role = aws_iam_role.lambda_token.arn
+ handler = "index.handler"
+ source_code_hash = data.archive_file.game_data.output_base64sha256
+ runtime = "nodejs20.x"
+ timeout = "10"
+ environment {
+ variables = {
+ ENVIRONMENT = "production"
+ LOG_LEVEL = "info"
+ AWS_DB_CLUSTER = format("%s.dsql.%s.on.aws", aws_dsql_cluster.edulense.identifier, data.aws_region.current.region)
+ }
+ }
+}
+
+resource "aws_lambda_function_url" "get_game_data_url" {
+ function_name = aws_lambda_function.game_data.function_name
+ authorization_type = "NONE"
+ cors {
+ allow_methods = ["GET", "POST", "DELETE"]
+ allow_origins = ["*"]
+ allow_headers = ["content-type"]
+ }
+}
+
+# Run npm install before zipping
+resource "null_resource" "npm_install_reflections" {
+ triggers = {
+ # Trigger npm install when package.json or lock file changes
+ package_json = filemd5("../lambda/reflections/package.json")
+ package_lock = filemd5("../lambda/reflections/package-lock.json")
+ }
+
+ provisioner "local-exec" {
+ command = "cd ../lambda/reflections && npm install"
+ }
+}
+
+data "archive_file" "reflections" {
+ type = "zip"
+ source_dir = "../lambda/reflections"
+ excludes = ["../lambda/reflections/reflections.zip"]
+ output_path = "../lambda/reflections/reflections.zip"
+
+ depends_on = [null_resource.npm_install_reflections]
+}
+
+resource "aws_lambda_function" "reflections" {
+ filename = data.archive_file.reflections.output_path
+ function_name = "reflections"
+ role = aws_iam_role.lambda_token.arn
+ handler = "index.handler"
+ source_code_hash = data.archive_file.reflections.output_base64sha256
+ runtime = "nodejs20.x"
+ timeout = "10"
+ environment {
+ variables = {
+ ENVIRONMENT = "production"
+ LOG_LEVEL = "info"
+ AWS_DB_CLUSTER = format("%s.dsql.%s.on.aws", aws_dsql_cluster.edulense.identifier, data.aws_region.current.region)
+ }
+ }
+}
+
+resource "aws_lambda_function_url" "get_reflections_url" {
+ function_name = aws_lambda_function.reflections.function_name
+ authorization_type = "NONE"
+ cors {
+ allow_methods = ["GET", "POST"]
+ allow_origins = ["*"]
+ allow_headers = ["content-type"]
+ }
+}
\ No newline at end of file