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> conversation) async { - final response = await ApiService().httpPost( - Uri.parse(llm.url), - headers: { - "Authorization": 'Bearer ${llm.apiKey}', - "Content-Type": "application/json", - }, - body: jsonEncode({ - "model": llm.model, - "messages": conversation.map((m) { - return {"role": m["role"], "content": m["content"]}; - }).toList(), - "temperature": 0.7, - "top_p": 0.9, - }), - ); - - if (response.statusCode != 200) { - return "Error from LLM: ${response.statusCode} => ${response.body}"; - } + if (llm.apiKey == "") { + final response = await LocalLLMService().runModel2(conversation.map((m) { + return {"role": m["role"], "content": m["content"]}; + }).toList()); - final jsonData = jsonDecode(response.body); - if (jsonData["choices"] == null || jsonData["choices"].isEmpty) { - return null; - } + if (response == "") { + return null; + } + return response; + } else { + final response = await ApiService().httpPost( + Uri.parse(llm.url), + headers: { + "Authorization": 'Bearer ${llm.apiKey}', + "Content-Type": "application/json", + }, + body: jsonEncode({ + "model": llm.model, + "messages": conversation.map((m) { + return {"role": m["role"], "content": m["content"]}; + }).toList(), + "temperature": 0.7, + "top_p": 0.9, + }), + ); + + if (response.statusCode != 200) { + return "Error from LLM: ${response.statusCode} => ${response.body}"; + } - return jsonData["choices"][0]["message"]["content"]; + final jsonData = jsonDecode(response.body); + if (jsonData["choices"] == null || jsonData["choices"].isEmpty) { + return null; + } + + return jsonData["choices"][0]["message"]["content"]; + } } /// Parses a "CALL functionName(...)" string, calls the local function, returns its result diff --git a/LearningLens2025/frontend/lib/Api/llm/DeepSeek_api.dart b/LearningLens2025/frontend/lib/Api/llm/DeepSeek_api.dart index 846c2c81..8134cf4b 100644 --- a/LearningLens2025/frontend/lib/Api/llm/DeepSeek_api.dart +++ b/LearningLens2025/frontend/lib/Api/llm/DeepSeek_api.dart @@ -352,7 +352,6 @@ $inputText 'max_tokens': maxOutputTokens, 'stream': true, // critical for SSE }; - print("Streaming body: $body"); final client = http.Client(); try { final req = http.Request('POST', parsedUrl) diff --git a/LearningLens2025/frontend/lib/Api/llm/local_llm_service.dart b/LearningLens2025/frontend/lib/Api/llm/local_llm_service.dart index 94109102..5a3ea8f7 100644 --- a/LearningLens2025/frontend/lib/Api/llm/local_llm_service.dart +++ b/LearningLens2025/frontend/lib/Api/llm/local_llm_service.dart @@ -158,7 +158,7 @@ class LocalLLMService implements LLM { int requestId = await fllamaChat(request, (response, responseJson, done) { responseBuff.add(response); - //print(response); + print(response); if (done) { _runningRequestId = null; finalResponse = _getFinalResponse(responseBuff); @@ -197,6 +197,7 @@ class LocalLLMService implements LLM { return "ERROR: Local LLM failed to produce a response"; } + // method to run model with previous contexts Future runModel2( List>? context, { int? maxTokenSet, @@ -343,8 +344,8 @@ class LocalLLMService implements LLM { builder: (context) => AlertDialog( title: const Text('⚠️Warning:'), content: Text( - 'The currently loaded Local LLM does not consistently generate a valid XML needed for rubric geneartion.\n' - 'Proceeding could result in invalid XML output error.\n\n' + 'The currently loaded Local LLM does not consistently generate a valid XML or json files used in Learning Management System.\n' + 'Proceeding could result in invalid XML or json output error.\n\n' 'The recommended model for this task is 7B or higher reasoning models (Qwen).\n' 'Do you want to continue anyway?', ), @@ -409,6 +410,7 @@ class LocalLLMService implements LLM { return await runModel2(context); } + // unused in local LLM; @override Future generate(String prompt) { // TODO: implement generate @@ -498,43 +500,139 @@ class LocalLLMService implements LLM { } Future> fetchModelKeys() async { - final url = Uri.parse( - 'https://raw.githubusercontent.com/ssung13/SWEN670F2025/main/models.csv', - ); + final downloadUrl = LocalStorageService.getLocalLLMDownloadURLPath(); + if (downloadUrl != '') { + final url = Uri.parse( + downloadUrl, + ); - final List modelKeys = []; + final List modelKeys = []; - try { - final response = await http.get(url); - if (response.statusCode == 200) { - final csvContent = response.body; - - for (var line in LineSplitter.split(csvContent)) { - if (line.trim().isEmpty) continue; - final parts = line.split(','); - if (parts.isNotEmpty) { - modelKeys.add(parts[0].trim()); + try { + final response = await http.get(url); + if (response.statusCode == 200) { + final csvContent = response.body; + + for (var line in LineSplitter.split(csvContent)) { + if (line.trim().isEmpty) continue; + final parts = line.split(','); + if (parts.isNotEmpty) { + modelKeys.add(parts[0].trim()); + } } + } else { + print('Failed to fetch CSV: ${response.statusCode}'); } - } else { - print('Failed to fetch CSV: ${response.statusCode}'); + } catch (e) { + print('Error fetching CSV: $e'); } - } catch (e) { - print('Error fetching CSV: $e'); - } - return modelKeys; + return modelKeys; + } else { + return []; + } } + // method for outputting stream. @override Stream chatStream( {List>? context, String? prompt, - double temperature = 0.7, - double topP = 1.0, - double frequencyPenalty = 0.0, - double presencePenalty = 0.0}) { - // TODO: implement chatStream - throw UnimplementedError(); + double? temperature, + double? topP, + double? frequencyPenalty, + double? presencePenalty}) async* { + try { + model = LocalStorageService.getLocalLLMPath(); + + List? convertedContext = context?.map((entry) { + final role = Role.values.firstWhere((r) => r.name == entry['role']); + final message = entry['content'] as String; + return Message(role, message); + }).toList(); + + double temperatureInput = temperature ?? 0.7; + + // model id for the web build (DO NOT use WEB for now / WIP). + String mlcModelId = MlcModelId.qwen05b; + + // model path for the desktop build. + String mmprojPath = ""; + + final request = OpenAiRequest( + tools: [ + if (_tool != null) + Tool( + name: _tool!.name, + jsonSchema: _tool!.parametersAsString, + ), + ], + maxTokens: maxOutputTokens.round(), + messages: convertedContext!, + numGpuLayers: 99, + /* this seems to have no adverse effects in environments w/o GPU support, ex. Android and web */ + modelPath: kIsWeb ? mlcModelId : model, + mmprojPath: mmprojPath, + frequencyPenalty: 0.0, + // Don't use below 1.1, LLMs without a repeat penalty + // will repeat the same token. + presencePenalty: 1.1, + topP: _topP, + // 22.9s for 249 input tokens with 20K context for SmolLM3. + // 22.9s for 249 input tokens with 4K context for SmolLM3. + contextSize: contextSize, + // Don't use 0.0, some models will repeat + // the same token. + temperature: temperatureInput, + logger: (log) { + if (log.contains('(); + String previous = ''; + int requestId = await fllamaChat(request, (response, responseJson, done) { + if (!controller.isClosed) { + if (response.isNotEmpty) { + if (response.startsWith(previous)) { + controller.add(response.substring(previous.length)); + previous = response; + } else { + // In case it doesn't follow pattern (rare) + controller.add(response); + previous = response; + } + } + if (done) { + controller.close(); + _runningRequestId = null; + } + } + }); + + _runningRequestId = requestId; + yield* controller.stream; + + while (_runningRequestId != null) { + await Future.delayed(const Duration(milliseconds: 100)); + } + } catch (e) { + print(e); + } } } diff --git a/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_classroom_api.dart b/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_classroom_api.dart index 05e82524..f067f878 100644 --- a/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_classroom_api.dart +++ b/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_classroom_api.dart @@ -201,7 +201,7 @@ class GoogleClassroomApi { int day = int.parse(dateParts[2]); int hours = int.parse(dateParts[3]); int minutes = int.parse(dateParts[4]); - String? topicId = await getTopicId(courseId, 'Quiz') ?? '755868506953'; + String? topicId = await getTopicId(courseId, 'quiz') ?? '755868506953'; print('topic id is : $topicId'); @@ -677,6 +677,130 @@ Future updateCourseWorkMaterial(String courseId, String materialId, String } } + // ----------------------------------------------------------------------- + // Rubric helpers (courses.courseWork.rubrics) + // ----------------------------------------------------------------------- + + Future?> createRubric( + String courseId, + String courseWorkId, + Map rubricBody, + ) async { + final token = await _getToken(); + if (token == null) return null; + + final url = Uri.parse( + 'https://classroom.googleapis.com/v1/courses/$courseId/courseWork/$courseWorkId/rubrics', + ); + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + + final response = + await http.post(url, headers: headers, body: jsonEncode(rubricBody)); + print('createRubric → ${response.statusCode} :: ${response.body}'); + if (response.statusCode == 200 || response.statusCode == 201) { + return jsonDecode(response.body) as Map; + } + return null; + } + + Future>> listRubrics( + String courseId, + String courseWorkId, + ) async { + final token = await _getToken(); + if (token == null) return const []; + + final url = Uri.parse( + 'https://classroom.googleapis.com/v1/courses/$courseId/courseWork/$courseWorkId/rubrics', + ); + final headers = {'Authorization': 'Bearer $token'}; + + final response = await http.get(url, headers: headers); + print('listRubrics → ${response.statusCode} :: ${response.body}'); + if (response.statusCode != 200) return const []; + + final payload = jsonDecode(response.body) as Map; + final rubrics = payload['rubrics'] as List? ?? const []; + return rubrics.cast>(); + } + + Future?> getRubric( + String courseId, + String courseWorkId, + String rubricId, + ) async { + final token = await _getToken(); + if (token == null) return null; + + final url = Uri.parse( + 'https://classroom.googleapis.com/v1/courses/$courseId/courseWork/$courseWorkId/rubrics/$rubricId', + ); + final headers = {'Authorization': 'Bearer $token'}; + + final response = await http.get(url, headers: headers); + print('getRubric → ${response.statusCode} :: ${response.body}'); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + return null; + } + + Future?> patchRubric( + String courseId, + String courseWorkId, + String rubricId, + Map rubricBody, { + List? updateFields, + }) async { + final token = await _getToken(); + if (token == null) return null; + + final queryParameters = {}; + if (updateFields != null && updateFields.isNotEmpty) { + queryParameters['updateMask'] = updateFields.join(','); + } + + final uri = Uri.https( + 'classroom.googleapis.com', + '/v1/courses/$courseId/courseWork/$courseWorkId/rubrics/$rubricId', + queryParameters.isEmpty ? null : queryParameters, + ); + + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + + final response = + await http.patch(uri, headers: headers, body: jsonEncode(rubricBody)); + print('patchRubric → ${response.statusCode} :: ${response.body}'); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + return null; + } + + Future deleteRubric( + String courseId, + String courseWorkId, + String rubricId, + ) async { + final token = await _getToken(); + if (token == null) return false; + + final url = Uri.parse( + 'https://classroom.googleapis.com/v1/courses/$courseId/courseWork/$courseWorkId/rubrics/$rubricId', + ); + final headers = {'Authorization': 'Bearer $token'}; + + final response = await http.delete(url, headers: headers); + print('deleteRubric → ${response.statusCode}'); + return response.statusCode == 200 || response.statusCode == 204; + } + // ----------------------------------------------------------------------- // Method to retrieve Google Form questions from an assignment diff --git a/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_lms_service.dart b/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_lms_service.dart index 3411f358..03e0b079 100644 --- a/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_lms_service.dart +++ b/LearningLens2025/frontend/lib/Api/lms/google_classroom/google_lms_service.dart @@ -1,17 +1,21 @@ import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:http/http.dart' as http; import 'package:learninglens_app/Api/lms/google_classroom/google_classroom_api.dart'; // Import the updated API import 'package:learninglens_app/Api/lms/lms_interface.dart'; +import 'package:learninglens_app/Views/assessments_view.dart'; import 'package:learninglens_app/beans/assignment.dart'; import 'package:learninglens_app/beans/course.dart'; import 'package:learninglens_app/beans/g_question_form_data.dart'; import 'package:learninglens_app/beans/grade.dart'; import 'package:learninglens_app/beans/moodle_rubric.dart'; +import 'package:learninglens_app/beans/override.dart'; import 'package:learninglens_app/beans/participant.dart'; import 'package:learninglens_app/beans/quiz.dart'; +import 'package:learninglens_app/beans/quiz_override'; import 'package:learninglens_app/beans/quiz_type.dart'; import 'package:learninglens_app/beans/submission.dart'; import 'package:learninglens_app/beans/submission_status.dart'; @@ -19,6 +23,7 @@ import 'package:learninglens_app/beans/submission_with_grade.dart'; import 'package:learninglens_app/services/api_service.dart'; import 'package:learninglens_app/services/local_storage_service.dart'; import 'package:xml/xml.dart' as xml; +import 'package:intl/intl.dart'; /// A Singleton class for Moodle API access implementing [LmsInterface]. class GoogleLmsService extends LmsInterface { @@ -64,6 +69,8 @@ class GoogleLmsService extends LmsInterface { String? profileImage; @override List? courses; + @override + List? overrides; late GoogleSignIn _googleSignIn; @@ -71,9 +78,15 @@ class GoogleLmsService extends LmsInterface { // Auth / Login // **************************************************************************************** @override - Future login(String username, String password, String baseURL) { - // TODO: implement google api code - throw UnimplementedError(); + Future login(String username, String password, String baseURL) async { + final clientId = + baseURL.isNotEmpty ? baseURL : LocalStorageService.getGoogleClientId(); + + if (clientId.isEmpty) { + throw ArgumentError('Google client ID is required for Classroom login.'); + } + + await loginOath(clientId); } Future loginOath(String clientID) async { @@ -148,8 +161,50 @@ class GoogleLmsService extends LmsInterface { @override Future isUserTeacher(List moodleCourses) async { - // TODO: implement google api code - throw UnimplementedError(); + if (_userToken == null) { + throw StateError('User not logged in to Google Classroom'); + } + if (moodleCourses.isEmpty) { + return false; + } + + final response = await ApiService().httpGet( + Uri.parse('$apiURL/courses?teacherId=me'), + headers: {'Authorization': 'Bearer $_userToken'}, + ); + + if (response.statusCode != 200) { + throw HttpException( + 'Failed to determine Google Classroom teacher status: ${response.body}'); + } + + final decodedJson = jsonDecode(response.body); + if (decodedJson is! Map) { + return false; + } + + final coursesJson = decodedJson['courses']; + if (coursesJson is! List) { + return false; + } + + final teacherCourseIds = {}; + for (final courseJson in coursesJson) { + if (courseJson is Map) { + final idString = courseJson['id']?.toString(); + final id = idString != null ? int.tryParse(idString) : null; + if (id != null) { + teacherCourseIds.add(id); + } + } + } + + for (final course in moodleCourses) { + if (teacherCourseIds.contains(course.id)) { + return true; + } + } + return false; } @override @@ -181,9 +236,16 @@ class GoogleLmsService extends LmsInterface { @override Future> getCourses() async { - // TODO: implement google api code - // Never called?? - throw UnimplementedError(); + if (_userToken == null) { + throw StateError('User not logged in to Google Classroom'); + } + + if (courses != null && courses!.isNotEmpty) { + return courses!; + } + + courses = await getUserCourses(); + return courses!; } @override @@ -219,9 +281,6 @@ class GoogleLmsService extends LmsInterface { headers: {'Authorization': 'Bearer $_userToken'}, ); - // TODO: remove after testing. - // print('Google: ${response.body}'); - if (response.statusCode != 200) { throw HttpException(response.body); } @@ -257,11 +316,11 @@ class GoogleLmsService extends LmsInterface { // Iterate and capture topicIds for (var topic in topics) { - if (topic['name'] == 'Quiz') { + if (topic['name'] == 'quiz') { course.quizTopicId = int.parse(topic['topicId']); // print('Id for quiz'); // print(topic['topicId']); - } else if (topic['name'] == 'Essay') { + } else if (topic['name'] == 'essay') { course.essayTopicId = int.parse(topic['topicId']); // print('Id for essay'); // print(topic['topicId']); @@ -297,8 +356,9 @@ class GoogleLmsService extends LmsInterface { if (studentsJson.containsKey('students')) { for (var student in studentsJson['students']) { participants.add(Participant( - id: student['userId'] - .hashCode, // Google Classroom does not provide numeric IDs + // TODO: Int will parse incorrectly. Will need to swap to String or BigInt + // e.g. 123456789012345 would parse to something like 123456789010000 + id: int.parse(student['profile']['id']), fullname: student['profile']['name']['fullName'], firstname: student['profile']['name']['givenName'], lastname: student['profile']['name']['familyName'], @@ -319,9 +379,53 @@ class GoogleLmsService extends LmsInterface { @override Future importQuiz(String courseid, String quizXml) async { - // TODO: implement google api code - throw UnimplementedError( - 'This feature is not supported by Google Classroom. Please contact the developer.'); + if (_userToken == null) { + throw StateError('User not logged in to Google Classroom'); + } + if (courseid.isEmpty) { + throw ArgumentError('courseid must not be empty'); + } + if (quizXml.trim().isEmpty) { + throw ArgumentError('quizXml must not be empty'); + } + + final quiz = Quiz.fromXmlString(quizXml); + final quizName = (quiz.name != null && quiz.name!.trim().isNotEmpty) + ? quiz.name! + : 'Imported Quiz'; + final quizDescription = quiz.description ?? ''; + + if (courses == null || courses!.isEmpty) { + try { + await getCourses(); + } catch (e) { + print('Unable to import quiz: $e'); + } + } + + final dueDate = + quiz.timeClose ?? DateTime.now().add(const Duration(days: 7)); + final formattedDueDate = formatDueDate(dueDate); + + final success = await createAndAssignQuizFromXml( + courseid, + quizName, + quizDescription, + quizXml, + formattedDueDate, + ); + + if (!success) { + throw HttpException('Failed to import quiz into Google Classroom'); + } + } + + String formatDueDate(DateTime date) { + final month = date.month.toString().padLeft(2, '0'); + final day = date.day.toString().padLeft(2, '0'); + final hour = date.hour.toString().padLeft(2, '0'); + final minute = date.minute.toString().padLeft(2, '0'); + return '${date.year}-$month-$day-$hour-$minute'; } @override @@ -336,6 +440,8 @@ class GoogleLmsService extends LmsInterface { headers: {'Authorization': 'Bearer $_userToken'}, ); + topicId ??= courses?.firstWhere((c) => c.id == courseID).quizTopicId; + // print('quizlist: ${response.body}'); if (response.statusCode != 200) { @@ -372,7 +478,8 @@ class GoogleLmsService extends LmsInterface { @override Future createQuiz(String courseid, String quizname, String quizintro, - String sectionid, String timeopen, String timeclose) async { + String sectionid, String timeopen, String timeclose, + {List individualStudentsOptions = const []}) async { print('Creating quiz in Google Classroom...'); print('Course ID: $courseid'); print('Quiz Name: $quizname'); @@ -385,8 +492,8 @@ class GoogleLmsService extends LmsInterface { // Convert timeopen to ISO 8601 format String formattedTimeOpen = DateTime.parse(timeopen).toIso8601String(); - String? assignmentId = await createAssignmentHelper( - courseid, quizname, quizintro, sectionid, formattedTimeOpen); + String? assignmentId = await createAssignmentHelper(courseid, quizname, + quizintro, sectionid, formattedTimeOpen, individualStudentsOptions); if (assignmentId != null) { return int.parse(assignmentId); @@ -400,8 +507,13 @@ class GoogleLmsService extends LmsInterface { } } - Future createAssignmentHelper(String courseId, String title, - String description, String responderUri, String dueDate) async { + Future createAssignmentHelper( + String courseId, + String title, + String description, + String responderUri, + String dueDate, + List individualStudentsOptions) async { print('Creating assignment in Google Classroom... Inside helper'); print('Course ID: $courseId'); print('Title: $title'); @@ -425,7 +537,7 @@ class GoogleLmsService extends LmsInterface { // Parse the dueDate string DateTime parsedDate = DateTime.parse(dueDate); - final body = jsonEncode({ + var requestBody = { "title": title, "description": description, "workType": "ASSIGNMENT", @@ -445,7 +557,15 @@ class GoogleLmsService extends LmsInterface { "link": {"url": responderUri} } ] - }); + }; + + if (individualStudentsOptions.isNotEmpty) { + requestBody['individualStudentsOptions'] = { + "studentIds": individualStudentsOptions + }; + } + + final body = jsonEncode(requestBody); // Print request details print('Request URL: $url'); @@ -711,6 +831,8 @@ class GoogleLmsService extends LmsInterface { throw StateError('User not logged in to Google Classroom'); } + topicId ??= courses?.firstWhere((c) => c.id == courseID).essayTopicId; + final response = await ApiService().httpGet( Uri.parse( 'https://classroom.googleapis.com/v1/courses/$courseID/courseWork'), @@ -743,24 +865,139 @@ class GoogleLmsService extends LmsInterface { return essayList; } + MapEntry? findCourseAssignment(int assignmentId) { + if (courses == null) return null; + for (final course in courses!) { + final assignments = course.essays ?? const []; + for (final assignment in assignments) { + if (assignment.id == assignmentId) { + return MapEntry(course, assignment); + } + } + } + return null; + } + + DateTime? _parsePickerDate(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return null; + + final stringDate = DateTime.tryParse(trimmed); + if (stringDate != null) { + return stringDate; + } + + try { + final formatter = DateFormat('dd MMMM yyyy HH:mm', 'en_US'); + return formatter.parseStrict(trimmed); + } on FormatException { + print('Failed to parse date string: $raw'); + return null; + } + } + @override + // TODO - google API doesn't have an endpoint to pass in the rubric like Moodle Future?> createAssignment( - String courseid, - String sectionid, - String assignmentName, - String startdate, - String enddate, - String rubricJson, - String description, - ) async { + String courseid, + String sectionid, + String assignmentName, + String startdate, + String enddate, + String rubricJson, + String description, + {List individualStudentsOptions = const []}) async { print('Creating assignment...'); print('Course ID: $courseid'); print('Section ID: $sectionid'); print('Assignment Name: $assignmentName'); print('Start Date: $startdate'); print('End Date: $enddate'); - // TODO: implement google api code - throw UnimplementedError(); + final token = _userToken ?? await _getToken(); + if (token == null) { + throw StateError('User not logged in to Google Classroom'); + } + + final dueDateTime = _parsePickerDate(enddate); + final availabilityDate = _parsePickerDate(startdate); + + final body = { + 'title': assignmentName, + 'description': description, + 'workType': 'ASSIGNMENT', + }; + + if (dueDateTime != null) { + body['dueDate'] = { + 'year': dueDateTime.year, + 'month': dueDateTime.month, + 'day': dueDateTime.day, + }; + body['dueTime'] = { + 'hours': dueDateTime.hour, + 'minutes': dueDateTime.minute, + 'seconds': dueDateTime.second, + }; + } + + if (individualStudentsOptions.isNotEmpty) { + body['assigneeMode'] = 'INDIVIDUAL_STUDENTS'; + body['individualStudentsOptions'] = { + "studentIds": individualStudentsOptions, + }; + } + + if (availabilityDate != null) { + body['scheduledTime'] = availabilityDate.toUtc().toIso8601String(); + } + + if (sectionid.trim().isNotEmpty) { + if (sectionid.trim().startsWith('http')) { + body['materials'] = [ + { + 'link': {'url': sectionid.trim()} + } + ]; + } else { + final resolvedTopicId = + await _classroomApi.getTopicId(courseid, sectionid.trim()); + final topicIdCandidate = resolvedTopicId ?? + (RegExp(r'^\d+$').hasMatch(sectionid.trim()) + ? sectionid.trim() + : null); + if (topicIdCandidate != null) { + body['topicId'] = topicIdCandidate; + } + } + } + + final url = Uri.parse('$apiURL/courses/$courseid/courseWork'); + final headers = { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + + try { + final response = + await http.post(url, headers: headers, body: jsonEncode(body)); + + print('Create assignment response: ${response.statusCode}'); + if (response.statusCode != 200) { + print('Body: ${response.body}'); + return null; + } + + final courseWorkResponse = jsonDecode(response.body); + if (courseWorkResponse is Map) { + return courseWorkResponse; + } + + return {'data': courseWorkResponse}; + } catch (e, st) { + print('Error creating assignment in Google Classroom: $e'); + print(st); + return null; + } } @override @@ -854,12 +1091,34 @@ class GoogleLmsService extends LmsInterface { @override Future getRubric(String assignmentid) async { - // TODO: implement google api code - print('Getting rubric...'); - print('Assignment ID: $assignmentid'); - throw UnimplementedError( - 'This feature is not supported by Google Classroom. Please contact the developer.'); + if (_userToken == null) { + throw StateError('User not logged in to Google Classroom'); + } + + final assignmentKey = int.tryParse(assignmentid); + if (assignmentKey == null) { + print('getRubric: Unable to parse assignmentId "$assignmentid"'); + return null; + } + + final target = findCourseAssignment(assignmentKey); + if (target == null) { + print('getRubric: Assignment $assignmentKey not found in cached courses'); + return null; + } + + final courseId = target.key.id.toString(); + final courseWorkId = target.value.id.toString(); + + final rubrics = await _classroomApi.listRubrics(courseId, courseWorkId); + + if (rubrics.isEmpty) { + return null; + } + + return MoodleRubric.empty().fromGoogleJson(rubrics.first); } + // **************************************************************************************** // Quiz creation and assignment with Answer Key //Short answer question 10 Points @@ -1145,4 +1404,133 @@ class GoogleLmsService extends LmsInterface { // TODO: implement uploadFileToDraft throw UnimplementedError(); } + + @override + Future refreshOverrides() async { + List override = []; + if (courses != null) { + for (Course c in courses!) { + List parts = await getCourseParticipants(c.id.toString()); + for (Participant p in parts) { + print(p.id); + } + var quizzes = (await getQuizzes(c.id, topicId: c.quizTopicId)) + .where((q) => q.individualStudentsOptions.isNotEmpty); + for (Quiz q in quizzes) { + for (int p in q.individualStudentsOptions) { + override.add(Override( + override.length, + "quiz", + q.id!, + q.name!, + c.id, + c.fullName, + p, + parts.firstWhere((part) => part.id == p).fullname, + q.timeClose, + null, + q.timeClose, + 0)); + } + } + var essays = (await getEssays(c.id, topicId: c.essayTopicId)) + .where((e) => e.individualStudentsOptions.isNotEmpty); + for (Assignment e in essays) { + for (int p in e.individualStudentsOptions) { + override.add(Override( + override.length, + "essay", + e.id, + e.name, + c.id, + c.fullName, + p, + parts.firstWhere((part) => part.id == p).fullname, + e.dueDate, + null, + e.cutoffDate, + 0)); + } + } + } + } + } + + @override + Future addEssayOverride( + {required int assignid, + int? courseId, + int? userId, + int? groupId, + int? allowsubmissionsfromdate, + int? dueDate, + int? cutoffDate, + int? timelimit, + int? sortorder}) async { + if (courseId == null) { + return ""; + } + var assignment = + getCourse(courseId).essays?.firstWhereOrNull((e) => e.id == assignid); + if (assignment == null) { + print(getCourse(courseId).essays); + return ""; + } + print("due date: $dueDate"); + var response = await createAssignment( + courseId.toString(), + "", + assignment.name, + (assignment.allowsubmissionsfromdate?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch) + .toString(), + (dueDate == null + ? assignment.dueDate?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch + : dueDate * 1000) + .toString(), + assignment.maxScore.toString(), + assignment.description, + individualStudentsOptions: [userId!]); + print(response); + return "Created essay override"; + } + + @override + Future addQuizOverride( + {required int quizId, + int? courseId, + int? userId, + int? groupId, + int? timeOpen, + int? timeClose, + int? timeLimit, + int? attempts, + String? password}) async { + if (courseId == null) { + return QuizOverride.empty(); + } + var assignment = + getCourse(courseId).quizzes?.firstWhereOrNull((e) => e.id == quizId); + if (assignment == null) { + return QuizOverride.empty(); + } + + await createQuiz( + courseId.toString(), + assignment.name!, + assignment.description ?? "", + "", + (timeOpen == null + ? assignment.timeOpen + : DateTime.fromMillisecondsSinceEpoch(timeOpen)) + .toString(), + (timeClose == null + ? assignment.timeClose?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch + : timeClose * 1000) + .toString(), + individualStudentsOptions: [userId!]); + return QuizOverride.empty(); + } } diff --git a/LearningLens2025/frontend/lib/Api/lms/lms_interface.dart b/LearningLens2025/frontend/lib/Api/lms/lms_interface.dart index 3320031d..a0137000 100644 --- a/LearningLens2025/frontend/lib/Api/lms/lms_interface.dart +++ b/LearningLens2025/frontend/lib/Api/lms/lms_interface.dart @@ -4,8 +4,10 @@ import 'package:learninglens_app/beans/assignment.dart'; import 'package:learninglens_app/beans/course.dart'; import 'package:learninglens_app/beans/grade.dart'; import 'package:learninglens_app/beans/moodle_rubric.dart'; +import 'package:learninglens_app/beans/override.dart'; import 'package:learninglens_app/beans/participant.dart'; import 'package:learninglens_app/beans/quiz.dart'; +import 'package:learninglens_app/beans/quiz_override'; import 'package:learninglens_app/beans/quiz_type.dart'; import 'package:learninglens_app/beans/submission.dart'; import 'package:learninglens_app/beans/submission_status.dart'; @@ -27,6 +29,7 @@ abstract class LmsInterface { String? profileImage; List? courses; UserRole? role; + List? overrides; // Authentication/Login methods Future login(String username, String password, String baseURL); @@ -104,6 +107,8 @@ abstract class LmsInterface { required int draftItemId, }); + Future refreshOverrides(); + Future submitAssignmentForGrading({ required int assignId, bool acceptSubmissionStatement, @@ -114,4 +119,31 @@ abstract class LmsInterface { }) { throw UnimplementedError(); } + + Future>> getSubmissionAttachments( + {required int assignId}) { + throw UnimplementedError(); + } + + Future addQuizOverride( + {required int quizId, + int? userId, + int? groupId, + int? timeOpen, + int? timeClose, + int? timeLimit, + int? attempts, + String? password, + int? courseId}); + + Future addEssayOverride( + {required int assignid, + int? userId, + int? groupId, + int? allowsubmissionsfromdate, + int? dueDate, + int? cutoffDate, + int? timelimit, + int? sortorder, + int? courseId}); } diff --git a/LearningLens2025/frontend/lib/Api/lms/moodle/moodle_lms_service.dart b/LearningLens2025/frontend/lib/Api/lms/moodle/moodle_lms_service.dart index 3fb7df69..ac7bb935 100644 --- a/LearningLens2025/frontend/lib/Api/lms/moodle/moodle_lms_service.dart +++ b/LearningLens2025/frontend/lib/Api/lms/moodle/moodle_lms_service.dart @@ -17,6 +17,7 @@ import 'package:learninglens_app/beans/submission.dart'; import 'package:learninglens_app/beans/submission_status.dart'; import 'package:learninglens_app/beans/submission_with_grade.dart'; import 'package:learninglens_app/services/api_service.dart'; +import 'package:learninglens_app/services/local_storage_service.dart'; /// A Singleton class for Moodle API access implementing [LmsInterface]. class MoodleLmsService implements LmsInterface { @@ -62,10 +63,11 @@ class MoodleLmsService implements LmsInterface { @override UserRole? role; - List? overrides; - int? userId; + @override + List? overrides; + String? get userToken => _userToken; // **************************************************************************************** @@ -120,7 +122,13 @@ class MoodleLmsService implements LmsInterface { fullName = userData['fullname']; profileImage = userData['userpictureurl']; - userId = userData['userid'] as int?; + final userId = userData['userid']; + if (userId != null) { + LocalStorageService.saveUserId(userId.toString()); + } else { + LocalStorageService.clearUserId(); + print('?? Moodle site info did not include userId'); + } // 4) Optionally load user courses right away courses = await getUserCourses(); @@ -132,6 +140,7 @@ class MoodleLmsService implements LmsInterface { return _userToken != null; } + @override Future refreshOverrides() async { List>> futures = [ _getQuizOverrides(), @@ -921,8 +930,10 @@ class MoodleLmsService implements LmsInterface { return []; } - final data = jsonDecode(response.body) as List; - if (data.isEmpty || data.first is! Map) { + final data = jsonDecode(response.body); + if (data.isEmpty || + data is! List || + data.first is! Map) { return []; } print('Rubric Grades Response: ${jsonEncode(data)}'); @@ -1010,16 +1021,17 @@ class MoodleLmsService implements LmsInterface { } } - Future addQuizOverride({ - required int quizId, - int? userId, - int? groupId, - int? timeOpen, - int? timeClose, - int? timeLimit, - int? attempts, - String? password, - }) async { + @override + Future addQuizOverride( + {required int quizId, + int? userId, + int? groupId, + int? timeOpen, + int? timeClose, + int? timeLimit, + int? attempts, + String? password, + int? courseId}) async { if (_userToken == null) throw StateError('User not logged in to Moodle'); final url = Uri.parse('$apiURL$serverUrl'); @@ -1052,16 +1064,17 @@ class MoodleLmsService implements LmsInterface { } } - Future addEssayOverride({ - required int assignid, - int? userId, - int? groupId, - int? allowsubmissionsfromdate, - int? dueDate, - int? cutoffDate, - int? timelimit, - int? sortorder, - }) async { + @override + Future addEssayOverride( + {required int assignid, + int? userId, + int? groupId, + int? allowsubmissionsfromdate, + int? dueDate, + int? cutoffDate, + int? timelimit, + int? sortorder, + int? courseId}) async { if (_userToken == null) throw StateError('User not logged in to Moodle'); final url = Uri.parse('$apiURL$serverUrl'); @@ -1624,4 +1637,93 @@ class MoodleLmsService implements LmsInterface { if (itemid is int) return itemid; throw StateError('Unexpected itemid response: ${res.body}'); } + + @override + + /// Gets the latest submission attachment for all assignment submissions + Future>> getSubmissionAttachments( + {required int assignId}) async { + if (_userToken == null) throw StateError('User not logged in to Moodle'); + + final res = await ApiService().httpPost( + Uri.parse(apiURL + serverUrl), + body: { + 'wstoken': _userToken!, + 'wsfunction': 'mod_assign_get_submissions', + 'moodlewsrestformat': 'json', + 'assignmentids[0]': '$assignId', + }, + ); + + if (res.statusCode != 200) { + throw HttpException(res.body); + } + + final data = jsonDecode(res.body); + if (data is Map && data['exception'] != null) { + throw HttpException('${data['exception']}: ${data['message']}'); + } + + final List> submissions = []; + final assignments = data['assignments'] as List?; + if (assignments == null || assignments.isEmpty) return submissions; + + for (final assignment in assignments) { + final subs = assignment['submissions'] as List?; + if (subs == null || subs.isEmpty) continue; + + // Group submissions by user ID + final Map> byUser = {}; + for (final sub in subs) { + final uid = sub['userid']; + if (uid == null) continue; + byUser.putIfAbsent(uid, () => []).add(sub); + } + + for (final entry in byUser.entries) { + final userId = entry.key; + final userSubs = entry.value; + + // Sort by modified or created time (newest first) + userSubs.sort((a, b) { + final at = a['timemodified'] ?? a['timecreated'] ?? 0; + final bt = b['timemodified'] ?? b['timecreated'] ?? 0; + return bt.compareTo(at); + }); + + final latest = userSubs.first; + + final plugins = latest['plugins'] as List?; + if (plugins != null) { + for (final plugin in plugins) { + if (plugin['type'] == 'file') { + final fileAreas = plugin['fileareas'] as List?; + if (fileAreas != null && fileAreas.isNotEmpty) { + final files = fileAreas[0]['files'] as List?; + if (files != null && files.isNotEmpty) { + String submissionUrl = files.first['fileurl']; + final uri = Uri.parse(submissionUrl); + // Need to append the token to the URl + final updatedUrl = uri.replace( + queryParameters: { + ...uri.queryParameters, + 'token': _userToken!, + }, + ).toString(); + + submissions.add({ + 'userid': userId, + 'submissionUrl': updatedUrl, + }); + break; + } + } + } + } + } + } + } + + return submissions; + } } diff --git a/LearningLens2025/frontend/lib/Api/lms/template/api_singleton.dart b/LearningLens2025/frontend/lib/Api/lms/template/api_singleton.dart index ad7e598c..00d46a2c 100644 --- a/LearningLens2025/frontend/lib/Api/lms/template/api_singleton.dart +++ b/LearningLens2025/frontend/lib/Api/lms/template/api_singleton.dart @@ -6,8 +6,10 @@ import 'package:learninglens_app/beans/course.dart'; import 'package:learninglens_app/beans/grade.dart'; import 'package:learninglens_app/beans/lesson_plan.dart'; import 'package:learninglens_app/beans/moodle_rubric.dart'; +import 'package:learninglens_app/beans/override.dart'; import 'package:learninglens_app/beans/participant.dart'; import 'package:learninglens_app/beans/quiz.dart'; +import 'package:learninglens_app/beans/quiz_override'; import 'package:learninglens_app/beans/quiz_type.dart'; import 'package:learninglens_app/beans/submission.dart'; import 'package:learninglens_app/beans/submission_status.dart'; @@ -54,6 +56,9 @@ class ApiSingleton implements LmsInterface { @override List? courses; + @override + List? overrides; + // Authentication/Login methods @override Future login(String username, String password, String baseURL) { @@ -307,4 +312,46 @@ class ApiSingleton implements LmsInterface { // TODO: implement getSubmissionStatusRaw throw UnimplementedError(); } + + @override + Future>> getSubmissionAttachments( + {required int assignId}) { + throw UnimplementedError(); + } + + @override + Future refreshOverrides() { + // TODO: implement refreshOverrides + throw UnimplementedError(); + } + + @override + Future addEssayOverride( + {required int assignid, + int? userId, + int? groupId, + int? allowsubmissionsfromdate, + int? dueDate, + int? cutoffDate, + int? timelimit, + int? sortorder, + int? courseId}) { + // TODO: implement addEssayOverride + throw UnimplementedError(); + } + + @override + Future addQuizOverride( + {required int quizId, + int? userId, + int? groupId, + int? timeOpen, + int? timeClose, + int? timeLimit, + int? attempts, + String? password, + int? courseId}) { + // TODO: implement addQuizOverride + throw UnimplementedError(); + } } diff --git a/LearningLens2025/frontend/lib/Controller/custom_appbar.dart b/LearningLens2025/frontend/lib/Controller/custom_appbar.dart index 386da8c0..9dbbd71c 100644 --- a/LearningLens2025/frontend/lib/Controller/custom_appbar.dart +++ b/LearningLens2025/frontend/lib/Controller/custom_appbar.dart @@ -102,17 +102,24 @@ class _CustomAppBarState extends State { ), actions: [ Flexible( - child: IconButton( - icon: Icon(Icons.science), // Science Icon - onPressed: !canAccessApp - ? null - : () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TextBasedFunctionCallerView()), - ); - }, + child: Visibility( + visible: isTeacher, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: IconButton( + icon: Icon(Icons.science), // Science Icon + onPressed: !canAccessApp + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + TextBasedFunctionCallerView()), + ); + }, + ), ), ), // Refresh button: Instead of relying on an external callback, diff --git a/LearningLens2025/frontend/lib/Games/flashcard_game.dart b/LearningLens2025/frontend/lib/Games/flashcard_game.dart index 1ffca606..78973483 100644 --- a/LearningLens2025/frontend/lib/Games/flashcard_game.dart +++ b/LearningLens2025/frontend/lib/Games/flashcard_game.dart @@ -21,6 +21,7 @@ class _FlashcardGameState extends State { int _currentIndex = 0; bool _showAnswer = false; + bool _completionReported = false; @override void initState() { @@ -34,6 +35,26 @@ class _FlashcardGameState extends State { _currentIndex = index; _showAnswer = false; }); + if (!_completionReported && index == _limitedQuestions.length - 1) { + _markComplete(auto: true); + } + } + } + + void _markComplete({bool auto = false}) { + if (_completionReported) return; + setState(() { + _completionReported = true; + }); + if (!widget.previewMode) { + widget.onComplete(); + if (!auto) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Flashcards completed!'), + ), + ); + } } } @@ -91,11 +112,19 @@ class _FlashcardGameState extends State { ElevatedButton( onPressed: _currentIndex < _limitedQuestions.length - 1 ? () => _goTo(_currentIndex + 1) - : null, + : () => _markComplete(auto: true), child: const Text('Next'), ), ], ), + if (!widget.previewMode) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ElevatedButton( + onPressed: _completionReported ? null : _markComplete, + child: Text(_completionReported ? 'Completed' : 'Mark Complete'), + ), + ), ], ); } diff --git a/LearningLens2025/frontend/lib/Games/game_result.dart b/LearningLens2025/frontend/lib/Games/game_result.dart new file mode 100644 index 00000000..9d914ef5 --- /dev/null +++ b/LearningLens2025/frontend/lib/Games/game_result.dart @@ -0,0 +1,11 @@ +class GamePlayResult { + final int score; + final int maxScore; + final DateTime completedAt; + + GamePlayResult({ + required this.score, + required this.maxScore, + DateTime? completedAt, + }) : completedAt = completedAt ?? DateTime.now(); +} diff --git a/LearningLens2025/frontend/lib/Games/matching_game.dart b/LearningLens2025/frontend/lib/Games/matching_game.dart index c513cade..b36a4bcd 100644 --- a/LearningLens2025/frontend/lib/Games/matching_game.dart +++ b/LearningLens2025/frontend/lib/Games/matching_game.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'game_result.dart'; + class MatchingGame extends StatefulWidget { final List> pairs; - final VoidCallback onComplete; + final void Function(GamePlayResult result) onComplete; final bool previewMode; const MatchingGame({ @@ -24,6 +26,7 @@ class _MatchingGameState extends State { int score = 0; bool gameFinished = false; List> results = []; + bool _completionReported = false; @override void initState() { @@ -59,6 +62,26 @@ class _MatchingGameState extends State { rightItems.shuffle(); } + String? _matchedTermForDefinition(String definition) { + for (final entry in userMatches.entries) { + if (entry.value == definition) { + return entry.key; + } + } + return null; + } + + void _reportCompletion() { + if (_completionReported || widget.previewMode) return; + _completionReported = true; + widget.onComplete( + GamePlayResult( + score: score, + maxScore: leftItems.length, + ), + ); + } + bool isGameComplete() { return userMatches.length == leftItems.length && userMatches.entries @@ -68,25 +91,28 @@ class _MatchingGameState extends State { @override Widget build(BuildContext context) { if (gameFinished) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Game Complete!', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 10), - Text('Score: $score / ${leftItems.length}', - style: const TextStyle(fontSize: 16)), - const SizedBox(height: 20), - ...results.map((r) => ListTile( - title: Text(r['term'] ?? ''), - subtitle: Text( - 'Your Match: ${r['selected']}\nCorrect: ${r['correct']}'), - trailing: Text(r['status'] ?? ''), - )), - const SizedBox(height: 20), - ], + _reportCompletion(); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Game Complete!', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + Text('Score: $score / ${leftItems.length}', + style: const TextStyle(fontSize: 16)), + const SizedBox(height: 20), + ...results.map((r) => ListTile( + title: Text(r['term'] ?? ''), + subtitle: Text( + 'Your Match: ${r['selected']}\nCorrect: ${r['correct']}'), + trailing: Text(r['status'] ?? ''), + )), + const SizedBox(height: 20), + ], + ), ); } return SingleChildScrollView( @@ -110,26 +136,58 @@ class _MatchingGameState extends State { children: [ // Left side: draggable terms Expanded( - child: Column( - children: leftItems.map((term) { - return Draggable( - data: term, - feedback: Material( + child: ListView.builder( + itemCount: leftItems.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final term = leftItems[index]; + final isMatched = userMatches.containsKey(term); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Draggable( + data: term, + feedback: Material( + child: Container( + padding: const EdgeInsets.all(8), + color: Colors.blueAccent, + child: Text(term, + style: const TextStyle(color: Colors.white)), + ), + ), + childWhenDragging: Opacity( + opacity: 0.5, + child: _MatchChip( + label: term, + color: Colors.blue.shade50, + ), + ), child: Container( - padding: const EdgeInsets.all(8), - color: Colors.blueAccent, - child: Text(term, - style: const TextStyle(color: Colors.white)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isMatched + ? Colors.greenAccent.withOpacity(0.6) + : Colors.blue.shade50, + border: Border.all( + color: isMatched + ? Colors.green.shade700 + : Colors.blue.shade200, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + term, + style: TextStyle( + fontWeight: isMatched + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), ), ), - childWhenDragging: - Opacity(opacity: 0.5, child: Text(term)), - child: Container( - padding: const EdgeInsets.all(8), - child: Text(term), - ), ); - }).toList(), + }, ), ), @@ -137,35 +195,62 @@ class _MatchingGameState extends State { // Right side: drop targets for definitions Expanded( - child: Column( - children: rightItems.map((definition) { - final matchedTerm = userMatches.entries - .firstWhere((e) => e.value == definition, - orElse: () => MapEntry('', '')) - .key; + child: ListView.builder( + itemCount: rightItems.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final definition = rightItems[index]; + final matchedTerm = _matchedTermForDefinition(definition); return DragTarget( builder: (context, candidateData, rejectedData) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 6), - padding: const EdgeInsets.all(8), - color: matchedTerm.isNotEmpty - ? Colors.greenAccent - : Colors.grey.shade200, - child: Text( - matchedTerm.isNotEmpty - ? '$matchedTerm → $definition' - : definition, - style: const TextStyle(fontSize: 14), + final isDropping = candidateData.isNotEmpty; + final hasMatch = matchedTerm != null; + return GestureDetector( + onTap: hasMatch + ? () { + setState(() { + userMatches.remove(matchedTerm); + }); + } + : null, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: hasMatch + ? Colors.greenAccent + : isDropping + ? Colors.blue.shade100 + : Colors.grey.shade200, + border: Border.all( + color: hasMatch + ? Colors.green.shade700 + : Colors.grey.shade400, + ), + ), + child: Text( + hasMatch + ? '$matchedTerm → $definition' + : definition, + style: const TextStyle(fontSize: 14), + ), ), ); }, onAccept: (term) { setState(() { + final existingTerm = + _matchedTermForDefinition(definition); + if (existingTerm != null) { + userMatches.remove(existingTerm); + } + userMatches.remove(term); userMatches[term] = definition; }); }, ); - }).toList(), + }, ), ), ], @@ -194,24 +279,34 @@ class _MatchingGameState extends State { setState(() { gameFinished = true; }); + _reportCompletion(); }, child: const Text('Submit Answers'), ), - if (gameFinished) - Padding( - padding: const EdgeInsets.only(top: 16.0), - child: OutlinedButton( - onPressed: widget.onComplete, - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.blue), - padding: - const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), - child: const Text('Close'), - ), - ), ], ), ); } } + +class _MatchChip extends StatelessWidget { + final String label; + final Color color; + + const _MatchChip({ + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: color, + ), + padding: const EdgeInsets.all(8), + child: Text(label), + ); + } +} diff --git a/LearningLens2025/frontend/lib/Games/quiz_game.dart b/LearningLens2025/frontend/lib/Games/quiz_game.dart index 5c1e1277..31931a5a 100644 --- a/LearningLens2025/frontend/lib/Games/quiz_game.dart +++ b/LearningLens2025/frontend/lib/Games/quiz_game.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'game_result.dart'; + class QuizGame extends StatefulWidget { final List> questions; - final VoidCallback onComplete; + final void Function(GamePlayResult result) onComplete; final bool previewMode; const QuizGame({ @@ -23,6 +25,7 @@ class _QuizGameState extends State { bool? wasCorrect; List> userAnswers = []; String? previewSelected; + bool _completionReported = false; void checkAnswer(String selected) { final correctAnswerIndex = widget.questions[currentIndex]['answer'] as int; @@ -69,13 +72,26 @@ class _QuizGameState extends State { currentIndex++; showResult = true; }); + _reportCompletion(questions.length); } } + void _reportCompletion(int totalQuestions) { + if (_completionReported || widget.previewMode) return; + _completionReported = true; + widget.onComplete( + GamePlayResult( + score: score, + maxScore: totalQuestions, + ), + ); + } + @override Widget build(BuildContext context) { final questions = widget.questions.take(5).toList(); if (currentIndex >= questions.length) { + _reportCompletion(questions.length); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/LearningLens2025/frontend/lib/Views/ai_log_screen.dart b/LearningLens2025/frontend/lib/Views/ai_log_screen.dart index 61ed97fc..2a1bed32 100644 --- a/LearningLens2025/frontend/lib/Views/ai_log_screen.dart +++ b/LearningLens2025/frontend/lib/Views/ai_log_screen.dart @@ -4,6 +4,7 @@ import 'package:excel/excel.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:intl/intl.dart'; import 'package:learninglens_app/Api/database/ai_logging_singleton.dart'; import 'package:learninglens_app/Api/lms/factory/lms_factory.dart'; @@ -425,7 +426,7 @@ class _AiLogScreenState extends State { showCheckboxColumn: false, sortColumnIndex: sortIndex, sortAscending: sortAsc, - dataRowMaxHeight: double.infinity, + dataRowMaxHeight: 70, columns: [ DataColumn( columnWidth: FixedColumnWidth(150), @@ -536,13 +537,14 @@ class _AiLogScreenState extends State { color: Colors.deepPurple, borderRadius: BorderRadius.circular(16), ), - child: Text( + child: SelectionArea( + child: Text( logSource.sortedData[selected!].getStringForColumn(3), style: TextStyle( color: Colors.white, fontSize: 16, ), - ))), + )))), Align( alignment: Alignment.centerLeft, child: Container( @@ -552,17 +554,29 @@ class _AiLogScreenState extends State { color: Colors.grey[300], borderRadius: BorderRadius.circular(16), ), - child: Text( - logSource.sortedData[selected!].getStringForColumn(4), - style: TextStyle( - color: Colors.black87, - fontSize: 16, + child: MarkdownBody( + data: logSource.sortedData[selected!] + .getStringForColumn(4), + selectable: true, + styleSheet: + MarkdownStyleSheet.fromTheme(Theme.of(context)) + .copyWith( + p: const TextStyle( + fontSize: 16, + color: Colors.black87, + ), + strong: const TextStyle(fontWeight: FontWeight.bold), + em: const TextStyle(fontStyle: FontStyle.italic), + a: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), ), ))), Align( alignment: Alignment.centerRight, child: Container( - margin: const EdgeInsets.fromLTRB(20, 6, 0, 6), + margin: const EdgeInsets.fromLTRB(0, 6, 20, 6), padding: const EdgeInsets.all(14), decoration: logSource.sortedData[selected!] .getStringForColumn(5) @@ -572,25 +586,36 @@ class _AiLogScreenState extends State { color: Colors.blue, borderRadius: BorderRadius.circular(16), ), - child: Text( - logSource.sortedData[selected!] + child: MarkdownBody( + data: logSource.sortedData[selected!] .getStringForColumn(5) .isEmpty ? "There was no micro-reflection for this AI prompt." : logSource.sortedData[selected!] .getStringForColumn(5), - style: TextStyle( - color: logSource.sortedData[selected!] - .getStringForColumn(5) - .isEmpty - ? Colors.grey - : Colors.white, - fontSize: 16, - fontStyle: logSource.sortedData[selected!] - .getStringForColumn(5) - .isEmpty - ? FontStyle.italic - : FontStyle.normal), + selectable: true, + styleSheet: + MarkdownStyleSheet.fromTheme(Theme.of(context)) + .copyWith( + p: TextStyle( + fontSize: 16, + color: logSource.sortedData[selected!] + .getStringForColumn(5) + .isEmpty + ? Colors.grey + : Colors.white, + fontStyle: logSource.sortedData[selected!] + .getStringForColumn(5) + .isEmpty + ? FontStyle.italic + : FontStyle.normal), + strong: const TextStyle(fontWeight: FontWeight.bold), + em: const TextStyle(fontStyle: FontStyle.italic), + a: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), ))) ])), actions: [ @@ -627,13 +652,36 @@ class _AiLogSource extends DataTableSource { } DataCell cellFor(int row, int column) { - return DataCell(Text( - sortedData[row].getStringForColumn(column), - softWrap: true, - textAlign: TextAlign.start, - maxLines: 3, - overflow: TextOverflow.ellipsis, - )); + return DataCell(AiLog.isMarkdown(column) + ? Wrap( + clipBehavior: Clip.hardEdge, + direction: Axis.horizontal, + children: [ + MarkdownBody( + data: sortedData[row].getStringForColumn(column), + shrinkWrap: true, + softLineBreak: true, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(parentState.context)) + .copyWith( + p: const TextStyle( + color: Colors.black87, overflow: TextOverflow.ellipsis), + strong: const TextStyle(fontWeight: FontWeight.bold), + em: const TextStyle(fontStyle: FontStyle.italic), + a: const TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), + ) + ]) + : Text( + sortedData[row].getStringForColumn(column), + softWrap: true, + textAlign: TextAlign.start, + maxLines: 3, + overflow: TextOverflow.ellipsis, + )); } @override diff --git a/LearningLens2025/frontend/lib/Views/analytics_page.dart b/LearningLens2025/frontend/lib/Views/analytics_page.dart index e2c37ca6..a41e2c2d 100644 --- a/LearningLens2025/frontend/lib/Views/analytics_page.dart +++ b/LearningLens2025/frontend/lib/Views/analytics_page.dart @@ -945,7 +945,7 @@ class _AnalyticsPageState extends State { if (selectedLLM == LlmType.LOCAL) ...[ const SizedBox(height: 6), const Text( - "Running a Large Language Model (LLM) requires substantial hardware resources.\nWhile 7B or higher thinking (Qwen) models can be used to generate the analysis, it may produce inaccurate or misleading responses. Additionally, it will not generate any reference.\nFor optimal results, we strongly recommend using the external API for this task.\nPlease use the local LLM responsibly and independently verify any critical information.", + "Running a Large Language Model (LLM) locally typically requires substantial hardware resources.\nWe recommend using 7B or higher thinking (Qwen) models to generate the analysis. \nFor best results, we strongly recommend using the external LLM for this task.\nPlease use the local LLM responsibly and independently verify any critical information.", style: TextStyle( fontSize: 13, color: Colors.black54, @@ -1000,13 +1000,15 @@ class _AnalyticsPageState extends State { bool decision = await LocalLLMService() .showCancelConfirmationDialog(count: 10); if (decision) { - canceled = true; - isLoading = false; - _isAnalyzingSuccess = false; - _isAnalyzingFail = false; - _isAnalyzingAssignment = false; - _isAnalyzingAi = false; - _isAnalyzingStudents = false; + setState(() { + canceled = true; + isLoading = false; + _isAnalyzingSuccess = false; + _isAnalyzingFail = false; + _isAnalyzingAssignment = false; + _isAnalyzingAi = false; + _isAnalyzingStudents = false; + }); } }, style: TextButton.styleFrom( @@ -2001,12 +2003,12 @@ class _AnalyticsPageState extends State { ); return; } - canceled = false; Participant? selectedParticipant = participantsData.firstWhereOrNull((p) => p.id == selectedStudentId); try { setState(() { + canceled = false; _aiAnalysisAi = []; _aiAnalysisSuccess = []; _aiAnalysisFail = []; @@ -2042,8 +2044,11 @@ class _AnalyticsPageState extends State { String studentGrades = studentCourseData.map((data) { return "Student: ${data['Student Name']}, Assignment: ${data['Assessment']}, Type: ${data['Type']}, Grade: ${data['Grade']}, Due Date: ${DateTime.fromMillisecondsSinceEpoch(int.parse(data['Due Date']!))}"; }).join("\n"); - - futures.add(_analyzeStudentTrend(studentGrades, selectedGradeLevel)); + if (selectedLLM != LlmType.LOCAL) { + futures.add(_analyzeStudentTrend(studentGrades, selectedGradeLevel)); + } else { + await _analyzeStudentTrend(studentGrades, selectedGradeLevel); + } } else { setState(() { _isAnalyzingStudents = false; @@ -2091,12 +2096,19 @@ class _AnalyticsPageState extends State { }).join("\n"); if (studentSummary.trim().isNotEmpty) { - futures.addAll([ - _analyzeEssaySuccess(selectedAssessment.name, assignmentDescription, - studentSummary, selectedGradeLevel), - _analyzeEssayMisunderstanding(selectedAssessment.name, - assignmentDescription, studentSummary, selectedGradeLevel), - ]); + if (selectedLLM != LlmType.LOCAL) { + futures.addAll([ + _analyzeEssaySuccess(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel), + _analyzeEssayMisunderstanding(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel), + ]); + } else { + await _analyzeEssaySuccess(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel); + await _analyzeEssayMisunderstanding(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel); + } } else { setState(() { _isAnalyzingSuccess = false; @@ -2105,8 +2117,13 @@ class _AnalyticsPageState extends State { } if (aiSummary.trim().isNotEmpty) { - futures.add(_analyzeEssayAiUse(selectedAssessment.name, - assignmentDescription, aiSummary, selectedGradeLevel)); + if (selectedLLM != LlmType.LOCAL) { + futures.add(_analyzeEssayAiUse(selectedAssessment.name, + assignmentDescription, aiSummary, selectedGradeLevel)); + } else { + await _analyzeEssayAiUse(selectedAssessment.name, + assignmentDescription, aiSummary, selectedGradeLevel); + } } else { setState(() { _isAnalyzingAi = false; @@ -2134,20 +2151,35 @@ class _AnalyticsPageState extends State { }).join("\n"); if (studentSummary.trim().isNotEmpty) { - await Future.wait([ - _analyzeStudentQuizSuccess( + if (selectedLLM != LlmType.LOCAL) { + futures.addAll([ + _analyzeStudentQuizSuccess( + selectedAssessment.name, + assignmentDescription, + studentSummary, + selectedParticipant.fullname, + selectedGradeLevel), + _analyzeStudentQuizMisunderstanding( + selectedAssessment.name, + assignmentDescription, + studentSummary, + selectedParticipant.fullname, + selectedGradeLevel) + ]); + } else { + await _analyzeStudentQuizSuccess( selectedAssessment.name, assignmentDescription, studentSummary, selectedParticipant.fullname, - selectedGradeLevel), - _analyzeStudentQuizMisunderstanding( + selectedGradeLevel); + await _analyzeStudentQuizMisunderstanding( selectedAssessment.name, assignmentDescription, studentSummary, selectedParticipant.fullname, - selectedGradeLevel) - ]); + selectedGradeLevel); + } } else { setState(() { _isAnalyzingSuccess = false; @@ -2159,12 +2191,19 @@ class _AnalyticsPageState extends State { return "Question: ${q.questionText}, Percent Correct: ${computePercentCorrect(q).toStringAsFixed(2)}%, Number Correct: ${q.numCorrect}, Number Incorrect: ${q.numIncorrect}, Total Attempts: ${q.totalAttempts}"; }).join("\n"); if (studentSummary.trim().isNotEmpty) { - futures.addAll([ - _analyzeCourseQuizSuccess(selectedAssessment.name, - assignmentDescription, studentSummary, selectedGradeLevel), - _analyzeCourseQuizMisunderstanding(selectedAssessment.name, - assignmentDescription, studentSummary, selectedGradeLevel) - ]); + if (selectedLLM != LlmType.LOCAL) { + futures.addAll([ + _analyzeCourseQuizSuccess(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel), + _analyzeCourseQuizMisunderstanding(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel) + ]); + } else { + await _analyzeCourseQuizSuccess(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel); + await _analyzeCourseQuizMisunderstanding(selectedAssessment.name, + assignmentDescription, studentSummary, selectedGradeLevel); + } } else { setState(() { _isAnalyzingSuccess = false; @@ -2173,9 +2212,8 @@ class _AnalyticsPageState extends State { } } } - if (!canceled) { - await Future.wait(futures); - await Future.wait([ + if (selectedLLM != LlmType.LOCAL) { + List futures2 = [ _analyzeAssignmentImprovements( selectedAssessment.name, selectedCourse.fullName, @@ -2194,11 +2232,28 @@ class _AnalyticsPageState extends State { : _aiAnalysisSuccess[0]["Summary"], _aiAnalysisFail.isEmpty ? "" : _aiAnalysisFail[0]["Summary"], selectedGradeLevel) - ]); - setState(() { - _lastAnalysisCompletionTime = DateFormat.yMd().format(DateTime.now()); - }); + ]; + await Future.wait(futures); + await Future.wait(futures2); + } else { + await _analyzeAssignmentImprovements( + selectedAssessment.name, + selectedCourse.fullName, + assignmentDescription, + _aiAnalysisSuccess.isEmpty ? "" : _aiAnalysisSuccess[0]["Summary"], + _aiAnalysisFail.isEmpty ? "" : _aiAnalysisFail[0]["Summary"], + selectedGradeLevel); + await _analyzeCourseImprovements( + selectedAssessment.name, + selectedCourse.fullName, + assignmentDescription, + _aiAnalysisSuccess.isEmpty ? "" : _aiAnalysisSuccess[0]["Summary"], + _aiAnalysisFail.isEmpty ? "" : _aiAnalysisFail[0]["Summary"], + selectedGradeLevel); } + setState(() { + _lastAnalysisCompletionTime = DateFormat.yMd().format(DateTime.now()); + }); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/LearningLens2025/frontend/lib/Views/dashboard.dart b/LearningLens2025/frontend/lib/Views/dashboard.dart index e76fae9c..3e42d1de 100644 --- a/LearningLens2025/frontend/lib/Views/dashboard.dart +++ b/LearningLens2025/frontend/lib/Views/dashboard.dart @@ -13,6 +13,7 @@ import 'package:learninglens_app/Views/iep_page.dart'; import 'package:learninglens_app/Views/lesson_plans.dart'; import 'package:learninglens_app/Views/nav_card.dart'; import 'package:learninglens_app/Views/program_assessment_view.dart'; +import 'package:learninglens_app/Views/student_reflections_page.dart'; import 'package:learninglens_app/Views/user_settings.dart'; import 'package:learninglens_app/services/local_storage_service.dart'; @@ -355,13 +356,16 @@ class TeacherDashboard extends StatelessWidget { { 'title': 'Games', 'description': 'Participate in games assigned to you.', - 'onPressed': null, + 'onPressed': () => Navigator.pushNamed(context, '/gamification'), 'icon': Icons.videogame_asset_outlined }, { 'title': 'Reflections', 'description': 'Reflect on your use of AI for your assignments.', - 'onPressed': null, + 'onPressed': () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StudentReflectionsPage())), 'icon': Icons.note_add_outlined }, ]; diff --git a/LearningLens2025/frontend/lib/Views/edit_reflection_questions_page.dart b/LearningLens2025/frontend/lib/Views/edit_reflection_questions_page.dart new file mode 100644 index 00000000..d375851d --- /dev/null +++ b/LearningLens2025/frontend/lib/Views/edit_reflection_questions_page.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:learninglens_app/Api/lms/factory/lms_factory.dart'; +import 'package:learninglens_app/Controller/custom_appbar.dart'; + +class EditReflectionQuestionsPage extends StatefulWidget { + final String assignmentId; + final String courseId; + final List initialQuestions; + + const EditReflectionQuestionsPage({ + Key? key, + required this.assignmentId, + required this.courseId, + required this.initialQuestions, + }) : super(key: key); + + @override + State createState() => + _EditReflectionQuestionsPageState(); +} + +class _EditReflectionQuestionsPageState + extends State { + List _controllers = []; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _controllers = widget.initialQuestions + .map((q) => TextEditingController(text: q)) + .toList(); + if (_controllers.isEmpty) { + _controllers.add(TextEditingController()); + } + } + + void addQuestionField() { + setState(() { + _controllers.add(TextEditingController()); + }); + } + + void removeQuestionField(int index) { + setState(() { + _controllers.removeAt(index); + }); + } + + Future saveQuestions() async { + if (!_formKey.currentState!.validate()) return; + + List questions = + _controllers.map((controller) => controller.text.trim()).toList(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Questions saved successfully!')), + ); + Navigator.pop(context, questions); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar( + title: 'Edit Reflection Questions', + userprofileurl: LmsFactory.getLmsService().profileImage ?? '', + ), + body: SingleChildScrollView( + padding: EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reflection Questions', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + SizedBox(height: 12), + ..._controllers.asMap().entries.map( + (entry) { + int index = entry.key; + TextEditingController controller = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + Expanded( + child: TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: 'Question ${index + 1}', + border: OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a question'; + } + return null; + }, + ), + ), + SizedBox(width: 8), + if (_controllers.length > 1) + IconButton( + icon: Icon(Icons.delete, color: Colors.red), + onPressed: () => removeQuestionField(index), + ), + ], + ), + ); + }, + ), + SizedBox(height: 12), + Row( + children: [ + ElevatedButton.icon( + onPressed: addQuestionField, + icon: Icon(Icons.add), + label: Text('Add Question'), + ), + SizedBox(width: 12), + ElevatedButton( + onPressed: saveQuestions, + child: Text('Save Questions'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/LearningLens2025/frontend/lib/Views/essay_assistant.dart b/LearningLens2025/frontend/lib/Views/essay_assistant.dart index c19c1fbb..d3f3cc07 100644 --- a/LearningLens2025/frontend/lib/Views/essay_assistant.dart +++ b/LearningLens2025/frontend/lib/Views/essay_assistant.dart @@ -34,6 +34,10 @@ import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import 'package:vsc_quill_delta_to_html/vsc_quill_delta_to_html.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:super_clipboard/super_clipboard.dart'; +import 'package:flutter_quill_delta_from_html/flutter_quill_delta_from_html.dart' + as qh; // Api import 'package:learninglens_app/Api/database/ai_logging_singleton.dart'; @@ -44,10 +48,8 @@ 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'; // Controller import 'package:learninglens_app/Controller/custom_appbar.dart'; -// Views // beans import 'package:learninglens_app/beans/ai_log.dart'; import 'package:learninglens_app/beans/assignment.dart'; @@ -59,6 +61,8 @@ import 'package:learninglens_app/beans/participant.dart'; import 'package:learninglens_app/services/LLMContextBuilder.dart'; import 'package:learninglens_app/services/local_storage_service.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'; /* ──────────────────────────────────────────────────────────────────────────── * 2) Top-level types / utilities @@ -129,11 +133,14 @@ Future getCourseForEssay(Assignment essay) async { } Future getStudentForEssay(int courseId) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); final lms = LmsFactory.getLmsService(); - final uid = (lms as MoodleLmsService).userId; + final uid = prefs.getString('userId'); + final int? uidInt = uid != null ? int.tryParse(uid) : null; + final participants = await lms.getCourseParticipants(courseId.toString()); for (final p in participants) { - if (p.id == uid) return p; + if (p.id == uidInt) return p; } return null; } @@ -149,6 +156,67 @@ bool _hasKeyFor(LlmType llm) { return LocalStorageService.userHasLlmKey(llm); } +/// Convert markdown text to HTML +String _markdownToHTML(String markdownText) { + return md.markdownToHtml(markdownText); +} + +String _markdownToPlain(String mdText) { + // lightweight, good enough for fallback + var s = mdText; + + // Replace fenced code blocks + s = s.replaceAllMapped(RegExp(r'```[\s\S]*?```'), (m) { + final inner = m + .group(0)! + .replaceFirst(RegExp(r'^```[^\n]*\n'), '') + .replaceAll('```', ''); + return inner; + }); + + // Inline code + s = s.replaceAll(RegExp(r'`([^`]*)`'), r'\1'); + + // Images + s = s.replaceAll(RegExp(r'!\[.*?\]\(.*?\)'), ''); + + // Links → "text (url)" + s = s.replaceAllMapped( + RegExp(r'\[([^\]]+)\]\(([^)]+)\)'), (m) => '${m[1]} (${m[2]})'); + + // Remove emphasis, quotes, tildes + s = s.replaceAll(RegExp(r'(^|\s)[*_]{1,3}|\~\~|^>+\s*', multiLine: true), ''); + + // Bullet points + s = s.replaceAll(RegExp(r'^\s*[-*]\s+', multiLine: true), '• '); + + // Collapse triple newlines + s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n'); + + s = s.replaceAll(RegExp(r'\r\n?'), '\n'); // unify line endings + s = s.replaceAll( + RegExp(r'\n{2,}'), '\n\n'); // one blank line between paragraphs + + return s.trim(); +} + +/// Copy rich markdown as HTML to clipboard +Future _copyMarkdown(BuildContext context, String mdText) async { + final htmlText = _markdownToHTML(mdText); + final plain = _markdownToPlain(mdText); + + final item = DataWriterItem(); + item.add(Formats.htmlText(htmlText)); + item.add(Formats.plainText(plain)); + await SystemClipboard.instance?.write([item]); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied rich text to clipboard.')), + ); + } +} + /* ──────────────────────────────────────────────────────────────────────────── * 4) EssayAssistant Widget * ──────────────────────────────────────────────────────────────────────────── */ @@ -188,11 +256,12 @@ class _EssayAssistantState extends State { getCourseForEssay(_currentSession!.essay); // course of current essay Future get _currentStudent => getStudentForEssay( _currentSession!.essay.courseId); // student using the assistant - bool get _sessionActive => _currentSession != null; // Check if a session is active String get essayID => _currentSession?.id ?? ''; // Current essay/session ID + bool _localLlmAvail = !kIsWeb; + /// Start or continue a session for the given essay. Future _startSessionFor(Assignment essay, {bool replay = true}) async { // Get EssayKey @@ -261,6 +330,12 @@ class _EssayAssistantState extends State { void _cancelStreaming() async { if (!_isStreaming) return; + // local LLM has its own cancel + if (_selectedLLM == LlmType.LOCAL) { + LocalLLMService().cancel(); + return; + } + _isStreaming = false; await _streamSub?.cancel(); _streamSub = null; @@ -305,7 +380,6 @@ class _EssayAssistantState extends State { // ---------------- Left sidebar (essays) state ---------------- int? _selectedSidebarIndex; // selected essay index in the left list List _essays = []; // loaded from LMS (all courses or filtered) - bool _isReloadingEssays = false; // loading state final Map _statusCache = {}; // key = essayKey , value = EssayStatus @@ -378,21 +452,9 @@ class _EssayAssistantState extends State { _postIntroMsg(); // Quill controller with basic config (external rich paste enabled) - _quillDraftController = quill.QuillController.basic( - config: const quill.QuillControllerConfig( - clipboardConfig: quill.QuillClipboardConfig( - enableExternalRichPaste: true, - ), - ), - ); + _quillDraftController = quill.QuillController.basic(); // Notes editor - _quillNotesController = quill.QuillController.basic( - config: const quill.QuillControllerConfig( - clipboardConfig: quill.QuillClipboardConfig( - enableExternalRichPaste: true, - ), - ), - ); + _quillNotesController = quill.QuillController.basic(); // Load essays from LMS into left sidebar _loadEssays(); @@ -444,13 +506,13 @@ These are pre-built tools that perform focused tasks — such as checking gramma Use this to capture quick ideas, reminders, or references as you work. Notes stay linked to your essay session so you won’t lose your thoughts between sessions. **Draft Editor:**\n -This is your main writing space. You can write freely, review feedback from the assistant, and make revisions directly here. When you’re ready, you can export or submit your final essay. +This is your main writing space. You can write freely, review feedback from the assistant, and make revisions directly here. When you’re ready, you can export or submit your final essay.\n -**Select an essay from the left sidebar to get started!** +**Select an essay from the left sidebar to get started!**\n -Tip: The assistant adapts to your mode and notes, so the more context you provide, the better it can help. +Tip: The assistant adapts to your mode and notes, so the more context you provide, the better it can help.\n -⚠️ **Important Notice** ⚠️ +⚠️ **Important Notice** ⚠️\n **The Essay Assistant provides AI-generated feedback and content suggestions. While it strives for accuracy and quality, it is your responsibility to verify all information, citations, and factual claims before submission. Always review and edit the final essay to ensure it meets your academic standards and requirements.** '''); @@ -627,8 +689,7 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid if (key.isEmpty) return _appendError('Deepseek key missing'); aiModel = DeepseekLLM(key); case LlmType.LOCAL: - // TODO: Handle this case. - throw UnimplementedError(); + aiModel = LocalLLMService(); } final fullContext = generateContext( permTokens: permContext, @@ -637,7 +698,7 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid llmContextSize: aiModel.contextSize, maxOutputTokens: aiModel.maxOutputTokens, ); - print('Full context for LLM:\n$fullContext'); + //print('Full context for LLM:\n$fullContext'); // Log the user's turn immediately _currentSession!.chatLog.add(ChatTurn(role: 'user', content: userPrompt)); @@ -777,8 +838,8 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid _selectedLLM, microReflection ?? '', ); - await AILoggingSingleton().addLog(log); + print('AI interaction logged successfully.'); } catch (err, st) { print('AI logging failed: $err\n$st'); } @@ -980,16 +1041,11 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid * ────────────────────────────────────────────────────────────────────────── */ /// Load essays for the current user (all courses). Pass a courseId if needed. - Future _loadEssays({int? courseId, bool toast = false}) async { + Future _loadEssays({int? courseId}) async { if (!mounted) return; - setState(() => _isReloadingEssays = true); - try { final all = await getAllEssays(courseId); - - final filtered = all - .where((a) => !_isOverdue(a)) // keep your “not overdue” filter - .toList() + final filtered = all.where((a) => !_isOverdue(a)).toList() ..sort((a, b) { final ad = _effectiveDue(a); final bd = _effectiveDue(b); @@ -1000,13 +1056,8 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid }); if (mounted) setState(() => _essays = filtered); - if (toast && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Essays reloaded')), - ); - } - } finally { - if (mounted) setState(() => _isReloadingEssays = false); + } catch (_) { + // optional: you can log or ignore silently } } @@ -1216,9 +1267,8 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid SizedBox( width: 280, child: Material( - color: Theme.of(context).colorScheme.surface, - child: Column( - children: [ + color: Theme.of(context).colorScheme.surface, + child: Column(children: [ // Sidebar header row Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), @@ -1229,20 +1279,7 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid Text('Essay Assignments', style: Theme.of(context).textTheme.titleMedium), const Spacer(), - IconButton( - tooltip: 'Reload', - onPressed: _isReloadingEssays - ? null - : () => _loadEssays(toast: true), - icon: _isReloadingEssays - ? SizedBox( - width: 18, - height: 18, - child: const CircularProgressIndicator( - strokeWidth: 2), - ) - : const Icon(Icons.refresh), - ), + // (removed reload button) ], ), ), @@ -1250,55 +1287,49 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid // List of essay items (from LMS) Expanded( - child: RefreshIndicator( - onRefresh: () => _loadEssays(), - child: ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: _essays.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, i) { - final assignment = _essays[i]; - final selected = _selectedSidebarIndex == i; - final dueText = assignment.dueDate == null - ? 'No due date' - : assignment.dueDate! - .toLocal() - .toIso8601String() - .split('T') - .first; - final status = _statusOf(assignment); - - 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(status), - onTap: () async { - setState(() => _selectedSidebarIndex = i); - final started = - await _openEssayDialog(context, assignment); - if (!started && mounted) { - _selectActiveEssayIfAny(); - } - }, - ); - }, - ), + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + itemCount: _essays.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final assignment = _essays[i]; + final selected = _selectedSidebarIndex == i; + final dueText = assignment.dueDate == null + ? 'No due date' + : assignment.dueDate! + .toLocal() + .toIso8601String() + .split('T') + .first; + final status = _statusOf(assignment); + + 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(status), + onTap: () async { + setState(() => _selectedSidebarIndex = i); + final started = + await _openEssayDialog(context, assignment); + if (!started && mounted) { + _selectActiveEssayIfAny(); + } + }, + ); + }, ), - ), - ], - ), - ), + ) + ])), ), // =================== CENTER: Chat column =================== @@ -1344,34 +1375,38 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid return Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 12.0), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: MarkdownBody( - data: text, - selectable: - true, // lets users highlight/copy text - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context)) - .copyWith( - p: const TextStyle( - fontSize: 16, - color: Colors.black87, - height: 1.46), - strong: const TextStyle( - fontWeight: FontWeight.bold), - em: const TextStyle( - fontStyle: FontStyle.italic), - a: const TextStyle( - color: Colors.blueAccent, - decoration: TextDecoration.underline, - ), + child: _AssistantMessageCard( + text: + text, // raw markdown that you already computed above + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: MarkdownBody( + data: text, + selectable: + true, // lets users highlight/copy text + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context)) + .copyWith( + p: const TextStyle( + fontSize: 16, + color: Colors.black87, + height: 1.46), + strong: const TextStyle( + fontWeight: FontWeight.bold), + em: const TextStyle( + fontStyle: FontStyle.italic), + a: const TextStyle( + color: Colors.blueAccent, + decoration: + TextDecoration.underline, + )), ), ), ), @@ -1693,7 +1728,8 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid underline: const SizedBox.shrink(), onChanged: (LlmType? newValue) { if (newValue == null) return; - if (_hasKeyFor(newValue)) { + if (newValue == LlmType.LOCAL || + _hasKeyFor(newValue)) { setState(() => _selectedLLM = newValue); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -1704,7 +1740,10 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid } }, items: LlmType.values.map((llm) { - final enabled = _hasKeyFor(llm); + final enabled = (llm == LlmType.LOCAL && + LocalStorageService.getLocalLLMPath() != "" && + _localLlmAvail) || + _hasKeyFor(llm); return DropdownMenuItem( value: llm, enabled: enabled, @@ -2016,11 +2055,138 @@ Tip: The assistant adapts to your mode and notes, so the more context you provid } } + Future _richPaste( + BuildContext editorCtx, quill.QuillController controller) async { + final sys = SystemClipboard.instance; + if (sys == null) { + // super_clipboard not available → let default paste run + Actions.invoke( + editorCtx, const PasteTextIntent(SelectionChangedCause.keyboard)); + return; + } + + final reader = await sys.read(); + + String normalizeLineBreaks(String rawHtml) { + var h = rawHtml; + final startIdx = h.indexOf(''); + final endIdx = h.indexOf(''); + if (startIdx != -1 && endIdx != -1 && endIdx > startIdx) { + h = h.substring(startIdx + ''.length, endIdx); + } + h = h.replaceAll(RegExp(r'', caseSensitive: false), '\n'); + h = h.replaceAll(RegExp(r'\n{2,}'), '

'); + if (!RegExp(r'$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