From 2939ee90a355b23650e12233ee87f2a745678f01 Mon Sep 17 00:00:00 2001 From: Mohamed Eldahtoury Date: Mon, 29 Jun 2026 16:03:43 +0300 Subject: [PATCH] feat: remove all SaaS element --- .github/workflows/backend-deployment.yaml | 88 -- .../firebase-hosting-pull-request.yml | 30 - Caddyfile | 16 + .../Api/Controllers/AnnouncementController.cs | 22 +- .../Api/Controllers/AssignmentController.cs | 64 +- backend/Api/Controllers/ClassController.cs | 23 +- .../Api/Controllers/GradeSheetController.cs | 71 +- backend/Api/Controllers/MeetingController.cs | 47 +- backend/Api/Controllers/QuizController.cs | 54 +- backend/Api/Controllers/StaffController.cs | 48 +- backend/Api/Controllers/StudentController.cs | 44 +- backend/Api/Controllers/TenantController.cs | 207 ----- backend/Api/Controllers/UserController.cs | 17 +- backend/Api/Filters/TenantFilter.cs | 33 - backend/Api/Middleware/ExceptionMiddleware.cs | 9 +- backend/Api/Program.cs | 207 ----- backend/Api/appsettings.Development.json | 2 +- backend/Api/appsettings.json | 64 +- .../Services/AnnouncementServiceTests.cs | 1 - .../Services/AssignmentServiceTests.cs | 1 - .../Services/GradeSheetServiceTests.cs | 1 - .../Services/QuizServiceTests.cs | 1 - .../Services/TenantServiceTests.cs | 193 ----- .../Application/DTOs/Tenant/ChangePlanDTO.cs | 11 - .../DTOs/Tenant/OrganizationCreationDTO.cs | 19 - backend/Application/DTOs/Tenant/PaymentDTO.cs | 23 - .../DTOs/Tenant/SingleClassCreationDTO.cs | 19 - .../DTOs/Tenant/TenantDeletionDTO.cs | 11 - .../DTOs/Tenant/TenantUpdateDTO.cs | 9 - .../Application/DTOs/Tenant/TokenTenantDTO.cs | 20 - .../Services/AnnouncementService.cs | 15 +- .../Application/Services/AssignmentService.cs | 60 +- backend/Application/Services/AuthService.cs | 8 +- .../Application/Services/BaseClassService.cs | 16 +- .../Application/Services/BillingService.cs | 114 --- backend/Application/Services/ClassService.cs | 51 +- .../Application/Services/GradeSheetService.cs | 71 +- .../Application/Services/MeetingService.cs | 29 +- .../Services/PlanEnforcementService.cs | 64 -- backend/Application/Services/QuizService.cs | 107 +-- backend/Application/Services/StaffService.cs | 88 +- .../Application/Services/StudentService.cs | 91 +- backend/Application/Services/TenantService.cs | 159 ---- backend/Application/Services/UserService.cs | 8 +- backend/Data/AppDbContext.cs | 15 +- backend/Data/Repositories/ClassRepository.cs | 29 +- backend/Data/Repositories/TenantRepository.cs | 72 -- backend/Data/Repositories/UserRepository.cs | 24 +- backend/Dockerfile | 23 + backend/Domain/Entities/BillingConfig.cs | 13 - backend/Domain/Entities/Class.cs | 7 +- backend/Domain/Entities/Tenant.cs | 39 - backend/Domain/Entities/User.cs | 7 +- backend/Domain/Enums/BillingPlans.cs | 9 - backend/Domain/Enums/SubscriptionPlan.cs | 17 - backend/Domain/Enums/TenantType.cs | 7 - .../Exceptions/InvalidTenantException.cs | 5 - .../Exceptions/PlanLimitReachedException.cs | 7 - .../Exceptions/TenantNotFoundException.cs | 5 - backend/Domain/Interfaces/IClassRepository.cs | 16 +- .../Interfaces/IPlanEnforcementService.cs | 10 - .../Domain/Interfaces/ITenantRepository.cs | 14 - backend/Domain/Interfaces/IUserJwtProvider.cs | 4 +- backend/Domain/Interfaces/IUserRepository.cs | 9 +- backend/Infrastructure/UserJwtProvider.cs | 10 +- backend/YASPO-Backend.sln | 104 ++- docker-compose.yml | 69 ++ lightsail/cors.json | 17 - mobile/android/app/google-services.json | 10 +- mobile/firebase.json | 2 +- mobile/ios/Runner/GoogleService-Info.plist | 14 +- mobile/lib/abstract/auth_service.dart | 11 - mobile/lib/abstract/tenant_service.dart | 10 - mobile/lib/core/api_url.dart | 3 +- mobile/lib/enums/tenant_type.dart | 1 - mobile/lib/firebase_options.dart | 20 +- mobile/lib/main.dart | 12 +- mobile/lib/model/tenant/tenant.dart | 31 - mobile/lib/model/user/user.dart | 5 - .../lib/services/remote_tenant_service.dart | 81 -- .../add_announcement_screen.dart | 196 ----- .../announcements/announcement_widget.dart | 164 ---- .../announcements/announcements_header.dart | 60 -- .../announcements/announcements_screen.dart | 176 ---- .../staff/assignments/assignment_header.dart | 53 -- .../staff/assignments/assignment_widget.dart | 181 ---- .../staff/assignments/assignments_screen.dart | 194 ----- .../assignments/create_assignment_screen.dart | 297 ------- .../assignments/update_assignment_screen.dart | 342 -------- .../class_settings/class_info_screen.dart | 202 ----- .../class_settings/class_settings_header.dart | 31 - .../class_settings/class_settings_screen.dart | 71 -- .../class_settings/delete_class_screen.dart | 176 ---- .../staff/class_settings/settings_tile.dart | 29 - .../grade_sheets/add_grade_sheet_screen.dart | 176 ---- .../grade_sheets/grade_sheets_header.dart | 49 -- .../grade_sheets/grade_sheets_screen.dart | 151 ---- .../staff/grade_sheets/sheet_item_widget.dart | 41 - .../add_student_grade_screen.dart | 178 ---- .../bulk_add_grade_records_screen.dart | 432 ---------- .../student_grade/choose_student_header.dart | 29 - .../student_grade/choose_student_screen.dart | 187 ----- .../grade_sheets_records_screen.dart | 220 ----- .../student_grade/record_header.dart | 278 ------- .../student_grade/record_widget.dart | 249 ------ .../staff/meetings/meeting_room_screen.dart | 775 ------------------ .../staff/meetings/meetings_screen.dart | 578 ------------- .../staff/more/app_info_section.dart | 97 --- .../staff/more/more_options_list.dart | 112 --- .../single-class/staff/more/more_screen.dart | 206 ----- .../app/single-class/staff/more/row_item.dart | 38 - .../staff/profile/edit_profile_screen.dart | 199 ----- .../staff/profile/profile_info_screen.dart | 220 ----- .../staff/profile/reset_password_screen.dart | 218 ----- .../staff/profile/update_info_screen.dart | 309 ------- .../profile/widgets/profile_info_header.dart | 211 ----- .../widgets/profile_management_actions.dart | 108 --- .../widgets/staff_permissions_section.dart | 57 -- .../profile/widgets/update_info_form.dart | 42 - .../profile/widgets/update_info_header.dart | 35 - .../staff/quizzes/add_question_screen.dart | 320 -------- .../quizzes/create_edit_quiz_screen.dart | 679 --------------- .../staff/quizzes/quiz_details_screen.dart | 316 ------- .../staff/quizzes/quiz_widget.dart | 94 --- .../staff/quizzes/quizzes_screen.dart | 221 ----- .../submissions/quiz_submissions_screen.dart | 176 ---- .../submission_details_screen.dart | 459 ----------- .../staff/staff/add_staff_screen.dart | 323 -------- .../staff/staff/staff_header.dart | 49 -- .../staff/staff/staff_screen.dart | 181 ---- .../staff/staff/staff_widget.dart | 61 -- .../single-class/staff/staff_app_host.dart | 122 --- .../staff/students/add_student_screen.dart | 251 ------ .../students/bulk_import_students_screen.dart | 442 ---------- .../staff/students/student_widget.dart | 61 -- .../staff/students/students_header.dart | 69 -- .../staff/students/students_screen.dart | 159 ---- .../announcement/announcement_screen.dart | 168 ---- .../announcement/announcement_widget.dart | 75 -- .../student/assignment/assignment_card.dart | 137 ---- .../assignment/assignment_details_screen.dart | 288 ------- .../student/assignment/assignment_screen.dart | 171 ---- .../assignment/student_assignment_header.dart | 23 - .../student_grade_sheets_screen.dart | 192 ----- .../student/more/more_screen.dart | 353 -------- .../single-class/student/more/row_item.dart | 38 - .../student/profile/profile_screen.dart | 125 --- .../single-class/student/quiz/quiz_card.dart | 176 ---- .../student/quiz/quiz_result_screen.dart | 182 ---- .../student/quiz/quiz_session_screen.dart | 460 ----------- .../student/quiz/student_quiz_screen.dart | 244 ------ .../student/student_app_host.dart | 87 -- .../lib/ui/onboarding/login/login_screen.dart | 6 +- .../notification/notification_screen.dart | 21 +- .../onboarding/onboarding_screen.dart | 14 +- .../tenant/tenant_class_name_screen.dart | 142 ---- .../tenant/tenant_details_widget.dart | 34 - .../tenant/tenant_payment_screen.dart | 291 ------- .../onboarding/tenant/tenant_plan_screen.dart | 127 --- .../onboarding/tenant/tenant_plan_widget.dart | 101 --- .../ui/onboarding/tenant/tenant_screen.dart | 189 ----- web/.env | 4 +- web/Dockerfile | 23 + web/src/App.jsx | 183 +---- .../staff/class-ui}/meetings/MeetingRoom.jsx | 4 +- .../staff/class-ui/meetings/MeetingsPage.jsx | 2 +- .../staff/announcements/AnnouncementCard.jsx | 196 ----- .../announcements/AnnouncementCreateModal.jsx | 107 --- .../staff/announcements/AnnouncementsPage.jsx | 213 ----- .../staff/assignments/AssignmentDetails.jsx | 162 ---- .../assignments/AssignmentSubmissions.jsx | 289 ------- .../staff/assignments/AssignmentsDisplay.jsx | 110 --- .../staff/assignments/AssignmentsLayout.jsx | 34 - .../staff/assignments/CreateNewAssignment.jsx | 319 ------- .../staff/assignments/DateModel.jsx | 301 ------- .../staff/assignments/FinishAssignment.jsx | 68 -- .../single-class/staff/common/StaffIndex.jsx | 19 - .../staff/common/layout/StaffLayout.jsx | 96 --- .../staff/grade-sheets/AddSheetModal.jsx | 65 -- .../staff/grade-sheets/CreateRecord.jsx | 67 -- .../staff/grade-sheets/GradeSheetsPage.jsx | 115 --- .../staff/grade-sheets/NewRecord.jsx | 148 ---- .../staff/grade-sheets/StudentGradesPage.jsx | 424 ---------- .../staff/grade-sheets/UpdateVisibility.jsx | 78 -- .../staff/meetings/MeetingsPage.jsx | 576 ------------- .../staff/meetings/PopoutMeetingRoom.jsx | 80 -- .../staff/quizzes/CreateQuizPage.jsx | 661 --------------- .../staff/quizzes/EditQuizPage.jsx | 606 -------------- .../staff/quizzes/QuizDetailsPage.jsx | 351 -------- .../staff/quizzes/QuizQuestionsViewPage.jsx | 200 ----- .../staff/quizzes/QuizSubmissionsPage.jsx | 361 -------- .../staff/quizzes/QuizzesPage.jsx | 101 --- .../staff/settings/BillingTab.jsx | 398 --------- .../single-class/staff/settings/InfoTab.jsx | 210 ----- .../staff/settings/SettingsPage.jsx | 97 --- .../staff/singleclass/ChooseTenant.jsx | 168 ---- .../single-class/staff/singleclass/Logo.jsx | 22 - .../staff/staff/ResetPasswordStaffModal.jsx | 153 ---- .../single-class/staff/staff/StaffModal.jsx | 343 -------- .../single-class/staff/staff/StaffPage.jsx | 526 ------------ .../staff/staff/UpdateStaffModal.jsx | 234 ------ .../students/ResetPasswordStudentModal.jsx | 151 ---- .../staff/students/StudentModal.jsx | 230 ------ .../staff/students/StudentsPage.jsx | 455 ---------- .../staff/students/UpdateStudentModal.jsx | 117 --- .../common/announcements/AnnouncementCard.jsx | 149 ---- .../announcements/AnnouncementsPage.jsx | 90 -- .../assignments/AssignmentDetailsPage.jsx | 274 ------- .../common/assignments/AssignmentsPage.jsx | 116 --- .../student/common/grades/GradesPage.jsx | 84 -- .../student/common/layout/StudentLayout.jsx | 64 -- .../student/common/meetings/MeetingsPage.jsx | 6 - .../student/common/quizzes/QuizResultPage.jsx | 240 ------ .../common/quizzes/QuizSessionPage.jsx | 429 ---------- .../common/quizzes/StartQuizButton.jsx | 90 -- .../common/quizzes/StudentQuizzesPage.jsx | 243 ------ web/src/ui/onboarding/auth/Login.jsx | 7 +- web/src/ui/onboarding/auth/Signup.jsx | 235 ------ web/src/ui/onboarding/flow/OnboardingFlow.jsx | 84 -- web/src/ui/onboarding/flow/PaymentStep.jsx | 258 ------ web/src/util/token.util.js | 6 +- 221 files changed, 602 insertions(+), 28802 deletions(-) delete mode 100644 .github/workflows/backend-deployment.yaml delete mode 100644 .github/workflows/firebase-hosting-pull-request.yml create mode 100644 Caddyfile delete mode 100644 backend/Api/Controllers/TenantController.cs delete mode 100644 backend/Api/Filters/TenantFilter.cs delete mode 100644 backend/Api/Program.cs delete mode 100644 backend/Application.Tests/Services/TenantServiceTests.cs delete mode 100644 backend/Application/DTOs/Tenant/ChangePlanDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/OrganizationCreationDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/PaymentDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/SingleClassCreationDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/TenantDeletionDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/TenantUpdateDTO.cs delete mode 100644 backend/Application/DTOs/Tenant/TokenTenantDTO.cs delete mode 100644 backend/Application/Services/BillingService.cs delete mode 100644 backend/Application/Services/PlanEnforcementService.cs delete mode 100644 backend/Application/Services/TenantService.cs delete mode 100644 backend/Data/Repositories/TenantRepository.cs create mode 100644 backend/Dockerfile delete mode 100644 backend/Domain/Entities/BillingConfig.cs delete mode 100644 backend/Domain/Entities/Tenant.cs delete mode 100644 backend/Domain/Enums/BillingPlans.cs delete mode 100644 backend/Domain/Enums/SubscriptionPlan.cs delete mode 100644 backend/Domain/Enums/TenantType.cs delete mode 100644 backend/Domain/Exceptions/InvalidTenantException.cs delete mode 100644 backend/Domain/Exceptions/PlanLimitReachedException.cs delete mode 100644 backend/Domain/Exceptions/TenantNotFoundException.cs delete mode 100644 backend/Domain/Interfaces/IPlanEnforcementService.cs delete mode 100644 backend/Domain/Interfaces/ITenantRepository.cs create mode 100644 docker-compose.yml delete mode 100644 lightsail/cors.json delete mode 100644 mobile/lib/abstract/tenant_service.dart delete mode 100644 mobile/lib/enums/tenant_type.dart delete mode 100644 mobile/lib/model/tenant/tenant.dart delete mode 100644 mobile/lib/services/remote_tenant_service.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/announcements/announcements_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/assignments/assignment_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/assignments/assignments_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/class_settings/class_settings_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/class_settings/class_settings_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/class_settings/settings_tile.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/grade_sheets_records_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/meetings/meeting_room_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/meetings/meetings_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/more/app_info_section.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/more/more_options_list.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/more/more_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/more/row_item.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/edit_profile_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/profile_info_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/widgets/profile_info_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/widgets/profile_management_actions.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/widgets/staff_permissions_section.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_form.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/add_question_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/quiz_details_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/quizzes_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/submissions/quiz_submissions_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/quizzes/submissions/submission_details_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/staff/staff_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/staff/staff_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/staff_app_host.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/students/student_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/students/students_header.dart delete mode 100644 mobile/lib/ui/app/single-class/staff/students/students_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/announcement/announcement_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/announcement/announcement_widget.dart delete mode 100644 mobile/lib/ui/app/single-class/student/assignment/assignment_card.dart delete mode 100644 mobile/lib/ui/app/single-class/student/assignment/assignment_details_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/assignment/assignment_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/assignment/student_assignment_header.dart delete mode 100644 mobile/lib/ui/app/single-class/student/grade_sheets/student_grade_sheets_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/more/more_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/more/row_item.dart delete mode 100644 mobile/lib/ui/app/single-class/student/profile/profile_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart delete mode 100644 mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart delete mode 100644 mobile/lib/ui/app/single-class/student/student_app_host.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_class_name_screen.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_details_widget.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_payment_screen.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_plan_screen.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_plan_widget.dart delete mode 100644 mobile/lib/ui/onboarding/tenant/tenant_screen.dart create mode 100644 web/Dockerfile rename web/src/ui/app/{single-class/staff => organization/staff/class-ui}/meetings/MeetingRoom.jsx (99%) delete mode 100644 web/src/ui/app/single-class/staff/announcements/AnnouncementCard.jsx delete mode 100644 web/src/ui/app/single-class/staff/announcements/AnnouncementCreateModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/announcements/AnnouncementsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/AssignmentDetails.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/AssignmentSubmissions.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/AssignmentsDisplay.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/AssignmentsLayout.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/CreateNewAssignment.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/DateModel.jsx delete mode 100644 web/src/ui/app/single-class/staff/assignments/FinishAssignment.jsx delete mode 100644 web/src/ui/app/single-class/staff/common/StaffIndex.jsx delete mode 100644 web/src/ui/app/single-class/staff/common/layout/StaffLayout.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/AddSheetModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/CreateRecord.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/GradeSheetsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/NewRecord.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/StudentGradesPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/grade-sheets/UpdateVisibility.jsx delete mode 100644 web/src/ui/app/single-class/staff/meetings/MeetingsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/meetings/PopoutMeetingRoom.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/CreateQuizPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/EditQuizPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/QuizDetailsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/QuizQuestionsViewPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/QuizSubmissionsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/quizzes/QuizzesPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/settings/BillingTab.jsx delete mode 100644 web/src/ui/app/single-class/staff/settings/InfoTab.jsx delete mode 100644 web/src/ui/app/single-class/staff/settings/SettingsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/singleclass/ChooseTenant.jsx delete mode 100644 web/src/ui/app/single-class/staff/singleclass/Logo.jsx delete mode 100644 web/src/ui/app/single-class/staff/staff/ResetPasswordStaffModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/staff/StaffModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/staff/StaffPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/staff/UpdateStaffModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/students/ResetPasswordStudentModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/students/StudentModal.jsx delete mode 100644 web/src/ui/app/single-class/staff/students/StudentsPage.jsx delete mode 100644 web/src/ui/app/single-class/staff/students/UpdateStudentModal.jsx delete mode 100644 web/src/ui/app/single-class/student/common/announcements/AnnouncementCard.jsx delete mode 100644 web/src/ui/app/single-class/student/common/announcements/AnnouncementsPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/assignments/AssignmentDetailsPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/assignments/AssignmentsPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/grades/GradesPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/layout/StudentLayout.jsx delete mode 100644 web/src/ui/app/single-class/student/common/meetings/MeetingsPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/quizzes/QuizResultPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/quizzes/QuizSessionPage.jsx delete mode 100644 web/src/ui/app/single-class/student/common/quizzes/StartQuizButton.jsx delete mode 100644 web/src/ui/app/single-class/student/common/quizzes/StudentQuizzesPage.jsx delete mode 100644 web/src/ui/onboarding/auth/Signup.jsx delete mode 100644 web/src/ui/onboarding/flow/OnboardingFlow.jsx delete mode 100644 web/src/ui/onboarding/flow/PaymentStep.jsx diff --git a/.github/workflows/backend-deployment.yaml b/.github/workflows/backend-deployment.yaml deleted file mode 100644 index c620cb02..00000000 --- a/.github/workflows/backend-deployment.yaml +++ /dev/null @@ -1,88 +0,0 @@ -name: Deploy Backend to Development Server - -on: - push: - branches: ["staging"] - -env: - DOTNET_VERSION: '9.0.x' - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - - name: Install .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: ${{ env.DOTNET_VERSION }} - - - name: Install EF Tools - run: dotnet tool install --global dotnet-ef --version 9.0.* - - - name: Generate Migration Script - working-directory: backend - run: | - dotnet ef migrations script \ - --idempotent \ - --project Data \ - -o migration.sql - - - name: Build Project - working-directory: backend - run: dotnet build Api --configuration Release - - - name: Move Migration Script - working-directory: backend - run: | - mkdir -p Api/bin/Release/migrations - mv migration.sql Api/bin/Release/migrations - - - name: Copy Binaries to Server - uses: appleboy/scp-action@v1 - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - port: ${{ secrets.PORT }} - key: ${{ secrets.KEY }} - source: "./backend/Api/bin/Release/" - target: "~/bins" - - - name: Setup production appsettings - uses: appleboy/ssh-action@v1 - with: - host: ${{ secrets.HOST }} - username: ${{ secrets.USERNAME }} - key: ${{ secrets.KEY }} - envs: PRODUCTION_APPSETTINGS - script: | - cat > ~/bins/backend/Api/bin/Release/net9.0/appsettings.Production.json < CreateAnnouncement([FromRoute] string classId, [FromBody] CreateAnnouncementDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } - var id = await _announcementService.CreateAnnouncement(tenant!, classId, request.Content, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, User.FindFirst("name")?.Value!); + var id = await _announcementService.CreateAnnouncement(classId, request.Content, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, User.FindFirst("name")?.Value!); return StatusCode(StatusCodes.Status201Created, new { id @@ -38,10 +32,7 @@ public async Task CreateAnnouncement([FromRoute] string classId, [HttpGet] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetAnnouncements([FromRoute] string classId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - var announcements = await _announcementService.GetAnnouncements(tenant!, classId, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + {var announcements = await _announcementService.GetAnnouncements(classId, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(announcements); } @@ -50,12 +41,9 @@ public async Task GetAnnouncements([FromRoute] string classId) [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task DeleteAnnouncement([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - try + {try { - await _announcementService.DeleteAnnouncement(tenant!, classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _announcementService.DeleteAnnouncement(classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return NoContent(); } diff --git a/backend/Api/Controllers/AssignmentController.cs b/backend/Api/Controllers/AssignmentController.cs index 0eec4362..b0668111 100644 --- a/backend/Api/Controllers/AssignmentController.cs +++ b/backend/Api/Controllers/AssignmentController.cs @@ -1,7 +1,5 @@ using Application.DTOs.Assignment; -using Application.DTOs.Tenant; using Application.Services; -using Api.Filters; using Domain.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("classes/{classId}/assignments")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class AssignmentController(AssignmentService assignmentService) : ControllerBase { public readonly AssignmentService _assignmentService = assignmentService; @@ -20,10 +17,7 @@ public class AssignmentController(AssignmentService assignmentService) : Control [HttpGet] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetAssignments([FromRoute] string classId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - var assignments = await _assignmentService.GetAssignments(tenant!, classId, Enum.Parse(User.FindFirst("type")?.Value!), User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + {var assignments = await _assignmentService.GetAssignments(classId, Enum.Parse(User.FindFirst("type")?.Value!), User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(assignments); } @@ -31,13 +25,11 @@ public async Task GetAssignments([FromRoute] string classId) [HttpGet("{id}")] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetAssignment([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - var assignment = await _assignmentService.GetAssignment(tenant!, classId, id, Enum.Parse(User.FindFirst("type")?.Value!), userId); + var assignment = await _assignmentService.GetAssignment(classId, id, Enum.Parse(User.FindFirst("type")?.Value!), userId); return Ok(assignment); } @@ -50,12 +42,10 @@ public async Task GetAssignment([FromRoute] string classId, [From [HttpGet("{id}/submissions")] [Authorize(Policy = "RequireEducator")] public async Task GetAssignmentSubmissions([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - var submissions = await _assignmentService.GetAssignmentSubmissions(tenant!, classId, id, userId); + var submissions = await _assignmentService.GetAssignmentSubmissions(classId, id, userId); return Ok(submissions); } catch (AssignmentNotFoundException) @@ -68,16 +58,14 @@ public async Task GetAssignmentSubmissions([FromRoute] string cla [HttpPut] [Authorize(Policy = "RequireEducator")] public async Task CreateAssignment([FromRoute] string classId, [FromBody] CreateAssignmentDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } var userId = HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - var id = await _assignmentService.CreateAssignment(tenant!, classId, request.Title, request.Description, request.MaxGrade, request.DueDate, userId, request.IsVisible); + var id = await _assignmentService.CreateAssignment(classId, request.Title, request.Description, request.MaxGrade, request.DueDate, userId, request.IsVisible); return StatusCode(StatusCodes.Status201Created, new { id }); } catch (InvalidAssignmentException) @@ -93,12 +81,10 @@ public async Task UpdateAssignment([FromRoute] string classId, [F if (!ModelState.IsValid) { return BadRequest(ModelState); - } - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + }var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - await _assignmentService.UpdateAssignment(tenant!, classId, id, request.Title, request.Description, request.DueDate, request.MaxGrade, request.IsVisible, userId); + await _assignmentService.UpdateAssignment(classId, id, request.Title, request.Description, request.DueDate, request.MaxGrade, request.IsVisible, userId); return Ok(); } catch (AssignmentNotFoundException) @@ -113,12 +99,10 @@ public async Task UpdateAssignment([FromRoute] string classId, [F [HttpDelete("{id}")] [Authorize(Policy = "RequireEducator")] public async Task DeleteAssignment([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - await _assignmentService.DeleteAssignment(tenant!, classId, id, userId); + await _assignmentService.DeleteAssignment(classId, id, userId); return NoContent(); } catch (AssignmentNotFoundException) @@ -131,14 +115,12 @@ public async Task DeleteAssignment([FromRoute] string classId, [F [HttpGet("{id}/submissions/{submissionId}/file")] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetSubmissionFile([FromRoute] string classId, [FromRoute] string id, [FromRoute] string submissionId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; var userType = Enum.Parse(User.FindFirst("type")?.Value!); try { - var response = await _assignmentService.GetSubmissionFile(tenant!, classId, id, submissionId, userId, userType); + var response = await _assignmentService.GetSubmissionFile(classId, id, submissionId, userId, userType); return Ok(response); } catch (AssignmentNotFoundException) @@ -154,12 +136,9 @@ public async Task GetSubmissionFile([FromRoute] string classId, [ [HttpPut("{id}/submissions")] [Authorize(Policy = "RequireStudent")] public async Task SubmitAssignment([FromRoute] string classId, [FromRoute] string id, [FromBody] AssignmentSubmissionRequestDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - try + {try { - var response = await _assignmentService.SubmitAssignment(tenant!, classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, request.Size, request.Type); + var response = await _assignmentService.SubmitAssignment(classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, request.Size, request.Type); return StatusCode(StatusCodes.Status201Created, response); } @@ -179,13 +158,11 @@ public async Task SubmitAssignment([FromRoute] string classId, [F [HttpPost("{id}/submissions/{submissionId}/grade")] [Authorize(Policy = "RequireEducator")] public async Task GradeSubmission([FromRoute] string classId, [FromRoute] string id, [FromRoute] string submissionId, [FromBody] GradeSubmissionRequestDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - await _assignmentService.GradeSubmission(tenant!, classId, id, submissionId, request.Grade, userId); + await _assignmentService.GradeSubmission(classId, id, submissionId, request.Grade, userId); return Ok(); } catch (AssignmentNotFoundException) @@ -205,12 +182,9 @@ public async Task GradeSubmission([FromRoute] string classId, [Fr [HttpPost("{id}/submissions/confirm")] [Authorize(Policy = "RequireStudent")] public async Task ConfirmAssignment([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - try + {try { - await _assignmentService.ConfirmSubmission(tenant!, classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _assignmentService.ConfirmSubmission(classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(); } diff --git a/backend/Api/Controllers/ClassController.cs b/backend/Api/Controllers/ClassController.cs index fe33d11f..b4b47493 100644 --- a/backend/Api/Controllers/ClassController.cs +++ b/backend/Api/Controllers/ClassController.cs @@ -1,7 +1,5 @@ using Application.DTOs.Class; -using Application.DTOs.Tenant; using Application.Services; -using Api.Filters; using Domain.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,7 +8,6 @@ namespace Api.Controllers; [Route("classes")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class ClassController(ClassService classService) : ControllerBase { private readonly ClassService _classService = classService; @@ -19,9 +16,7 @@ public class ClassController(ClassService classService) : ControllerBase [Authorize(Policy = "RequireViewAllClasses")] public async Task GetClasses() { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - var classes = await _classService.GetClasses(tenant!.Id); + var classes = await _classService.GetClasses(); return Ok(classes); } @@ -29,9 +24,7 @@ public async Task GetClasses() [Authorize(Policy = "RequireViewAllClasses")] public async Task GetClassById([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - var c = await _classService.GetClassById(id, tenant!.Id); + var c = await _classService.GetClassById(id); return Ok(c); } @@ -39,14 +32,12 @@ public async Task GetClassById([FromRoute] string id) [Authorize(Policy = "RequireManageClasses")] public async Task CreateClass([FromBody] CreateClassDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); } - var id = await _classService.CreateClass(tenant!.Id, request.Name); + var id = await _classService.CreateClass(request.Name); return Ok(new { id }); } @@ -54,14 +45,12 @@ public async Task CreateClass([FromBody] CreateClassDTO request) [Authorize(Policy = "RequireManageClasses")] public async Task UpdateClass([FromRoute] string id, [FromBody] UpdateClassDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); } - await _classService.UpdateClass(id, tenant!.Id, request.Name); + await _classService.UpdateClass(id, request.Name); return Ok(); } @@ -69,9 +58,7 @@ public async Task UpdateClass([FromRoute] string id, [FromBody] U [Authorize(Policy = "RequireManageClasses")] public async Task DeleteClass([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - await _classService.DeleteClass(id, tenant!.Id); + await _classService.DeleteClass(id); return Ok(); } } diff --git a/backend/Api/Controllers/GradeSheetController.cs b/backend/Api/Controllers/GradeSheetController.cs index 6bd7dd5d..08d12aae 100644 --- a/backend/Api/Controllers/GradeSheetController.cs +++ b/backend/Api/Controllers/GradeSheetController.cs @@ -1,6 +1,4 @@ -using Api.Filters; using Application.DTOs.GradeSheets; -using Application.DTOs.Tenant; using Application.Services; using Domain.Enums; using Domain.Exceptions; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("classes/{classId}/grade-sheets")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class GradeSheetController(GradeSheetService gradeSheetService) : ControllerBase { public readonly GradeSheetService _gradeSheetService = gradeSheetService; @@ -20,12 +17,10 @@ public class GradeSheetController(GradeSheetService gradeSheetService) : Control [HttpGet] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetGradeSheets([FromRoute] string classId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userType = Enum.Parse(User.FindFirst("type")?.Value!); + {var userType = Enum.Parse(User.FindFirst("type")?.Value!); var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var gradeSheets = await _gradeSheetService.GetGradeSheets(tenant!, classId, userType, userId!); + var gradeSheets = await _gradeSheetService.GetGradeSheets(classId, userType, userId!); return Ok(gradeSheets); } @@ -33,13 +28,11 @@ public async Task GetGradeSheets([FromRoute] string classId) [HttpGet("{id}/grade")] [Authorize(Policy = "RequireStudent")] public async Task GetMyGrade([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var studentId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var studentId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; try { - var grade = await _gradeSheetService.GetStudentGradeForSheet(tenant!, classId, id, studentId); + var grade = await _gradeSheetService.GetStudentGradeForSheet(classId, id, studentId); return Ok(grade); } catch (GradeSheetNotFoundException) @@ -56,12 +49,9 @@ public async Task GetMyGrade([FromRoute] string classId, [FromRou [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task GetGradeRecords([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - try + {try { - var gradeSheets = await _gradeSheetService.GetGradeSheetRecords(tenant!, classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + var gradeSheets = await _gradeSheetService.GetGradeSheetRecords(classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(gradeSheets); } @@ -75,15 +65,12 @@ public async Task GetGradeRecords([FromRoute] string classId, [Fr [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task CreateGradeSheet([FromRoute] string classId, [FromBody] CreateGradeSheetDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } - var id = await _gradeSheetService.CreateGradeSheet(request.Name, tenant!, classId, request.IsVisible, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + var id = await _gradeSheetService.CreateGradeSheet(request.Name!, classId, request.IsVisible, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return StatusCode(StatusCodes.Status201Created, new { id @@ -94,17 +81,14 @@ public async Task CreateGradeSheet([FromRoute] string classId, [F [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task AddStudentRecord([FromRoute] string classId, [FromRoute] string id, [FromBody] CreateGradeRecordDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - var recordId = await _gradeSheetService.AddStudentGrade(tenant!, classId, id, request.StudentId, request.Grade, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + var recordId = await _gradeSheetService.AddStudentGrade(classId, id, request.StudentId, request.Grade, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return StatusCode(StatusCodes.Status201Created, new { @@ -130,17 +114,14 @@ public async Task AddStudentRecord([FromRoute] string classId, [F [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task AddStudentRecords([FromRoute] string classId, [FromRoute] string id, [FromBody] List request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - var recordIds = await _gradeSheetService.AddStudentGrades(tenant!, classId, id, request, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + var recordIds = await _gradeSheetService.AddStudentGrades(classId, id, request, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return StatusCode(StatusCodes.Status201Created, new { @@ -166,17 +147,14 @@ public async Task AddStudentRecords([FromRoute] string classId, [ [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task DeleteStudentRecord([FromRoute] string classId, [FromRoute] string sheetId, [FromRoute] string recordId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _gradeSheetService.DeleteStudentRecord(tenant!, classId, sheetId, recordId, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _gradeSheetService.DeleteStudentRecord(classId, sheetId, recordId, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return NoContent(); } @@ -195,17 +173,14 @@ public async Task DeleteStudentRecord([FromRoute] string classId, [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task UpdateStudentRecord([FromRoute] string classId, [FromRoute] string sheetId, [FromRoute] string recordId, [FromBody] SetStudentGradeDTO setStudentGradeDTO) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _gradeSheetService.UpdateStudentRecord(tenant!, classId, sheetId, recordId, setStudentGradeDTO.Grade, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _gradeSheetService.UpdateStudentRecord(classId, sheetId, recordId, setStudentGradeDTO.Grade, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(); } @@ -229,17 +204,14 @@ public async Task UpdateStudentRecord([FromRoute] string classId, [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task DeleteGradeSheet([FromRoute] string classId, [FromRoute] string id) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _gradeSheetService.DeleteGradeSheet(tenant!, classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _gradeSheetService.DeleteGradeSheet(classId, id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return NoContent(); } @@ -253,17 +225,14 @@ public async Task DeleteGradeSheet([FromRoute] string classId, [F [Authorize(Policy = "RequireStaff")] [Authorize(Policy = "RequireEducator")] public async Task SetGradeSheetVisibility([FromRoute] string classId, [FromRoute] string id, [FromBody] SetGradeSheetVisibilityDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + {if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _gradeSheetService.SetGradeSheetVisibility(tenant!, classId, id, request.IsVisible, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); + await _gradeSheetService.SetGradeSheetVisibility(classId, id, request.IsVisible, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); return Ok(); } diff --git a/backend/Api/Controllers/MeetingController.cs b/backend/Api/Controllers/MeetingController.cs index 5da0d282..10d250c7 100644 --- a/backend/Api/Controllers/MeetingController.cs +++ b/backend/Api/Controllers/MeetingController.cs @@ -1,6 +1,4 @@ -using Api.Filters; using Application.DTOs.Meetings; -using Application.DTOs.Tenant; using Application.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -10,7 +8,6 @@ namespace Api.Controllers; [Route("classes/{classId}/meetings")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class MeetingController(MeetingService meetingService) : ControllerBase { private readonly MeetingService _meetingService = meetingService; @@ -18,11 +15,9 @@ public class MeetingController(MeetingService meetingService) : ControllerBase [HttpGet] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetMeetings([FromRoute] string classId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; - var meetings = await _meetingService.GetMeetings(tenant!, classId, userId); + var meetings = await _meetingService.GetMeetings(classId, userId); return Ok(meetings); } @@ -32,13 +27,10 @@ public async Task GetMeetings([FromRoute] string classId) public async Task StartMeeting([FromRoute] string classId, [FromBody] CreateMeetingDTO dto) { if (!ModelState.IsValid) - return BadRequest(ModelState); - - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + return BadRequest(ModelState);var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; var userName = User.FindFirst("name")?.Value ?? userId; - var (meeting, token) = await _meetingService.StartMeeting(tenant!, dto.Name, classId, userId, userName); + var (meeting, token) = await _meetingService.StartMeeting(dto.Name, classId, userId, userName); return StatusCode(StatusCodes.Status201Created, new { @@ -50,12 +42,10 @@ public async Task StartMeeting([FromRoute] string classId, [FromB [HttpPost("{meetingId:int}/join")] [Authorize(Policy = "RequireStudentOrEducator")] public async Task JoinMeeting([FromRoute] string classId, [FromRoute] int meetingId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; var userName = User.FindFirst("name")?.Value ?? userId; - var token = await _meetingService.JoinMeeting(tenant!, meetingId, classId, userId, userName); + var token = await _meetingService.JoinMeeting(meetingId, classId, userId, userName); return Ok(new { token }); } @@ -63,11 +53,9 @@ public async Task JoinMeeting([FromRoute] string classId, [FromRo [HttpPost("{meetingId:int}/end")] [Authorize(Policy = "RequireEducator")] public async Task EndMeeting([FromRoute] string classId, [FromRoute] int meetingId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; - await _meetingService.EndMeeting(tenant!, meetingId, classId, userId); + await _meetingService.EndMeeting(meetingId, classId, userId); return NoContent(); } @@ -75,11 +63,9 @@ public async Task EndMeeting([FromRoute] string classId, [FromRou [HttpPost("{meetingId:int}/leave")] [Authorize(Policy = "RequireStudentOrEducator")] public async Task LeaveMeeting([FromRoute] string classId, [FromRoute] int meetingId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; - await _meetingService.LeaveMeeting(tenant!, meetingId, classId, userId); + await _meetingService.LeaveMeeting(meetingId, classId, userId); return NoContent(); } @@ -87,11 +73,9 @@ public async Task LeaveMeeting([FromRoute] string classId, [FromR [HttpGet("{meetingId:int}/chat")] [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetChatMessages([FromRoute] string classId, [FromRoute] int meetingId) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + {var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; - var messages = await _meetingService.GetChatMessages(tenant!, meetingId, classId, userId); + var messages = await _meetingService.GetChatMessages(meetingId, classId, userId); return Ok(messages); } @@ -102,13 +86,10 @@ public async Task SendChatMessage( [FromRoute] string classId, [FromRoute] int meetingId, [FromBody] SendChatMessageDTO dto) { if (!ModelState.IsValid) - return BadRequest(ModelState); - - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; + return BadRequest(ModelState);var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value!; var userName = User.FindFirst("name")?.Value ?? userId; - var message = await _meetingService.SendChatMessage(tenant!, meetingId, classId, userId, userName, dto.Message); + var message = await _meetingService.SendChatMessage(meetingId, classId, userId, userName, dto.Message); return StatusCode(StatusCodes.Status201Created, message); } diff --git a/backend/Api/Controllers/QuizController.cs b/backend/Api/Controllers/QuizController.cs index 31c186f9..0fc25b56 100644 --- a/backend/Api/Controllers/QuizController.cs +++ b/backend/Api/Controllers/QuizController.cs @@ -1,6 +1,4 @@ -using Api.Filters; using Application.DTOs.Quiz; -using Application.DTOs.Tenant; using Application.Services; using Domain.Enums; using Domain.Exceptions; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("classes/{classId}/quizzes")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class QuizController(QuizService quizService) : ControllerBase { private readonly QuizService _quizService = quizService; @@ -21,12 +18,11 @@ public class QuizController(QuizService quizService) : ControllerBase [Authorize(Policy = "RequireStudentOrEducator")] public async Task GetAllQuizzesInClass([FromRoute] string classId) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; + var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; Enum.TryParse(User.FindFirst("type")!.Value, out UserType userType); - var quizzes = await _quizService.GetAllQuizzes(tenant!, classId, userType, userId); + var quizzes = await _quizService.GetAllQuizzes(classId, userType, userId); return Ok(quizzes); @@ -36,10 +32,9 @@ public async Task GetAllQuizzesInClass([FromRoute] string classId [Authorize(Policy = "RequireEducator")] public async Task GetQuizById([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var quiz = await _quizService.GetQuizById(tenant!, classId, id, userId); + var quiz = await _quizService.GetQuizById(classId, id, userId); return Ok(quiz); } @@ -48,15 +43,13 @@ public async Task GetQuizById([FromRoute] string classId, [FromRo [Authorize(Policy = "RequireEducator")] public async Task CreateQuiz([FromRoute] string classId, [FromBody] QuizRequestDto request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var id = await _quizService.CreateQuiz(tenant!, classId, userId, request, userId); + var id = await _quizService.CreateQuiz(classId, userId, request, userId); return StatusCode(StatusCodes.Status201Created, new { id @@ -67,13 +60,12 @@ public async Task CreateQuiz([FromRoute] string classId, [FromBod [Authorize(Policy = "RequireEducator")] public async Task UpdateQuiz([FromRoute] string classId, [FromRoute] string id, [FromBody] UpdateQuizRequestDto request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - await _quizService.UpdateQuiz(tenant!, classId, id, request, userId); + await _quizService.UpdateQuiz(classId, id, request, userId); return Ok(); } @@ -81,13 +73,12 @@ public async Task UpdateQuiz([FromRoute] string classId, [FromRou [Authorize(Policy = "RequireStudent")] public async Task StartQuiz([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var studentId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; + var studentId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; if (!ModelState.IsValid) { return BadRequest(ModelState); } - var response = await _quizService.StartQuiz(tenant!, classId, studentId, id, studentId); + var response = await _quizService.StartQuiz(classId, studentId, id, studentId); return Ok(response); } @@ -95,7 +86,6 @@ public async Task StartQuiz([FromRoute] string classId, [FromRout [Authorize(Policy = "RequireStudent")] public async Task SubmitQuiz([FromRoute] string classId, [FromRoute] string id, [FromBody] QuizSubmissionDto request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var studentId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; if (!ModelState.IsValid) @@ -103,7 +93,7 @@ public async Task SubmitQuiz([FromRoute] string classId, [FromRou return BadRequest(ModelState); } - var result = await _quizService.SubmitQuiz(tenant!, classId, studentId, id, request.Answers, studentId); + var result = await _quizService.SubmitQuiz(classId, studentId, id, request.Answers, studentId); return Ok(result); } @@ -111,10 +101,9 @@ public async Task SubmitQuiz([FromRoute] string classId, [FromRou [Authorize(Policy = "RequireStudent")] public async Task GetQuizScore([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var studentId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; + var studentId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var score = await _quizService.GetQuizScore(tenant!, classId, studentId, id, studentId); + var score = await _quizService.GetQuizScore(classId, studentId, id, studentId); return Ok(score); } @@ -122,10 +111,9 @@ public async Task GetQuizScore([FromRoute] string classId, [FromR [Authorize(Policy = "RequireEducator")] public async Task DeleteQuiz([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - await _quizService.DeleteQuiz(tenant!, classId, id, userId); + await _quizService.DeleteQuiz(classId, id, userId); return Ok(); } @@ -133,9 +121,8 @@ public async Task DeleteQuiz([FromRoute] string classId, [FromRou [Authorize(Policy = "RequireEducator")] public async Task GetQuizSubmissions([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var submissions = await _quizService.GetSubmissionsForQuiz(tenant!, classId, id, userId); + var submissions = await _quizService.GetSubmissionsForQuiz(classId, id, userId); return Ok(submissions); } @@ -143,9 +130,8 @@ public async Task GetQuizSubmissions([FromRoute] string classId, [Authorize(Policy = "RequireEducator")] public async Task GetSubmissionDetails([FromRoute] string classId, [FromRoute] string id, [FromRoute] string submissionId) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var submission = await _quizService.GetSubmissionDetails(tenant!, classId, id, submissionId, userId); + var submission = await _quizService.GetSubmissionDetails(classId, id, submissionId, userId); return Ok(submission); } @@ -153,9 +139,8 @@ public async Task GetSubmissionDetails([FromRoute] string classId [Authorize(Policy = "RequireEducator")] public async Task GetQuizAverageScore([FromRoute] string classId, [FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; - var averageScore = await _quizService.GetQuizAverageScore(tenant!, classId, id, userId); + var averageScore = await _quizService.GetQuizAverageScore(classId, id, userId); return Ok(new { averageScore }); } @@ -167,9 +152,7 @@ public async Task GenerateQuestions([FromRoute] string classId, [ { return BadRequest(ModelState); } - - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - var questions = await _quizService.GenerateQuestionsAsync(request, tenant!.Id); + var questions = await _quizService.GenerateQuestionsAsync(request); return Ok(questions); } @@ -177,7 +160,6 @@ public async Task GenerateQuestions([FromRoute] string classId, [ [Authorize(Policy = "RequireEducator")] public async Task GradeSubmission([FromRoute] string classId, [FromRoute] string id, [FromRoute] string submissionId, [FromBody] GradeSubmissionDto request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)!.Value; if (!ModelState.IsValid) @@ -185,7 +167,7 @@ public async Task GradeSubmission([FromRoute] string classId, [Fr return BadRequest(ModelState); } - await _quizService.GradeSubmission(tenant!, classId, id, submissionId, request, userId); + await _quizService.GradeSubmission(classId, id, submissionId, request, userId); return Ok(); } } diff --git a/backend/Api/Controllers/StaffController.cs b/backend/Api/Controllers/StaffController.cs index 180c4f54..1da1fb67 100644 --- a/backend/Api/Controllers/StaffController.cs +++ b/backend/Api/Controllers/StaffController.cs @@ -1,7 +1,5 @@ using Application.DTOs.Class; -using Application.DTOs.Staff; -using Application.DTOs.Tenant; -using Api.Filters; +using Application.DTOs.Staff; using Application.Services; using Domain.Enums; using Domain.Exceptions; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("staff")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class StaffController(StaffService staffService) : ControllerBase { private readonly StaffService _staffService = staffService; @@ -21,14 +18,12 @@ public class StaffController(StaffService staffService) : ControllerBase [Authorize(Policy = "RequireStaff")] public async Task GetStaff() { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); } - var staff = await _staffService.GetStaff(tenant!.Id); + var staff = await _staffService.GetStaff(); return Ok(staff); } @@ -37,8 +32,6 @@ public async Task GetStaff() [Authorize(Policy = "RequireStaff")] public async Task GetStaff([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -46,7 +39,7 @@ public async Task GetStaff([FromRoute] string id) try { - var staff = await _staffService.GetStaff(tenant!.Id, id); + var staff = await _staffService.GetStaff(id); return Ok(staff); } catch (UserNotFoundException) @@ -64,14 +57,13 @@ public async Task GetStaff([FromRoute] string id) [Authorize(Policy = "RequireManageStaff")] public async Task AddStaff([FromBody] AddStaffRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - var id = await _staffService.AddStaff(tenant!.Name, tenant!.Id, tenant!.Type, request.Name, request.Email, request.AutogeneratePassword, request.Password, request.SendInstructions, request.Permissions); + var id = await _staffService.AddStaff(request.Name, request.Email, request.AutogeneratePassword, request.Password, request.SendInstructions, request.Permissions); return Ok(new { id, @@ -95,11 +87,9 @@ public async Task AddStaff([FromBody] AddStaffRequestDTO request) [Authorize(Policy = "RequireManageStaff")] public async Task AssignClasses([FromRoute] string id, [FromBody] AssignClassesDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - try { - await _staffService.AssignClasses(tenant!.Id, id, request.ClassIds); + await _staffService.AssignClasses(id, request.ClassIds); return Ok(); } catch (UserNotFoundException) @@ -120,16 +110,14 @@ public async Task AssignClasses([FromRoute] string id, [FromBody] [Authorize(Policy = "RequireManageStaff")] public async Task UpdateStaffInfo([FromRoute] string id, [FromBody] UpdateStaffInfoRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _staffService.UpdateStaffInfo(id, tenant!.Id, request.Name, request.Email, request.Permissions); + await _staffService.UpdateStaffInfo(id, request.Name, request.Email, request.Permissions); return Ok(); } catch (UserNotFoundException) @@ -150,16 +138,14 @@ public async Task UpdateStaffInfo([FromRoute] string id, [FromBod [Authorize(Policy = "RequireManageStaff")] public async Task ResetStaffPassword([FromRoute] string id, [FromBody] ResetStaffPasswordRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _staffService.ResetStaffPassword(id, tenant!.Id, tenant.Name, request.AutogeneratePassword, request.Password, request.SendInstructions); + await _staffService.ResetStaffPassword(id, request.AutogeneratePassword, request.Password, request.SendInstructions); return Ok(); } catch (UserNotFoundException) @@ -176,14 +162,13 @@ public async Task ResetStaffPassword([FromRoute] string id, [From [Authorize(Policy = "RequireManageStaff")] public async Task DeleteStaff([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) + if (!ModelState.IsValid) { return BadRequest(ModelState); } try { - await _staffService.DeleteStaff(id, tenant!.Id); + await _staffService.DeleteStaff(id); return Ok(); } catch (UserNotFoundException) @@ -200,16 +185,9 @@ public async Task DeleteStaff([FromRoute] string id) [Authorize(Policy = "RequireManageStaff")] public async Task GetStaffClasses([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - try { - var classes = await _staffService.GetStaffClasses(tenant!.Id, id); - - if (tenant.Type == TenantType.SINGLE_CLASS) - { - return Ok(classes.FirstOrDefault()?.Id ?? string.Empty); - } + var classes = await _staffService.GetStaffClasses(id); return Ok(classes); } diff --git a/backend/Api/Controllers/StudentController.cs b/backend/Api/Controllers/StudentController.cs index 55639970..71cef297 100644 --- a/backend/Api/Controllers/StudentController.cs +++ b/backend/Api/Controllers/StudentController.cs @@ -1,7 +1,5 @@ -using Api.Filters; using Application.DTOs.Class; using Application.DTOs.Student; -using Application.DTOs.Tenant; using Application.Services; using Domain.Enums; using Domain.Exceptions; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("students")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class StudentController(StudentService studentService) : ControllerBase { private readonly StudentService _studentService = studentService; @@ -21,14 +18,12 @@ public class StudentController(StudentService studentService) : ControllerBase [Authorize(Policy = "RequireStaff")] public async Task GetStudents() { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); } - var students = await _studentService.GetStudents(tenant!.Id); + var students = await _studentService.GetStudents(); return Ok(students); } @@ -36,8 +31,6 @@ public async Task GetStudents() [Authorize(Policy = "RequireStaff")] public async Task GetStudent([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -45,7 +38,7 @@ public async Task GetStudent([FromRoute] string id) try { - var students = await _studentService.GetStudent(tenant!.Id, id); + var students = await _studentService.GetStudent(id); return Ok(students); } @@ -63,8 +56,6 @@ public async Task GetStudent([FromRoute] string id) [Authorize(Policy = "RequireManageStudents")] public async Task AddStudent([FromBody] AddStudentRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -72,7 +63,7 @@ public async Task AddStudent([FromBody] AddStudentRequestDTO requ try { - var studentId = await _studentService.AddStudent(tenant!.Name, tenant!.Id, tenant!.Type, request.Name, request.Email, request.AutogeneratePassword, request.Password, request.SendInstructions); + var studentId = await _studentService.AddStudent(request.Name, request.Email, request.AutogeneratePassword, request.Password, request.SendInstructions); return Ok(new { @@ -94,8 +85,6 @@ public async Task AddStudent([FromBody] AddStudentRequestDTO requ [Authorize(Policy = "RequireManageStudents")] public async Task AddStudents([FromBody] List request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -103,7 +92,7 @@ public async Task AddStudents([FromBody] List AddStudents([FromBody] List AssignClasses([FromRoute] string id, [FromBody] AssignClassesDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - try { - await _studentService.AssignClasses(tenant!.Id, id, request.ClassIds); + await _studentService.AssignClasses(id, request.ClassIds); return Ok(); } catch (UserNotFoundException) @@ -146,8 +133,6 @@ public async Task AssignClasses([FromRoute] string id, [FromBody] [Authorize(Policy = "RequireManageStudents")] public async Task UpdateStudentInfo([FromRoute] string id, [FromBody] UpdateStudentRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -155,7 +140,7 @@ public async Task UpdateStudentInfo([FromRoute] string id, [FromB try { - await _studentService.UpdateInfo(tenant!.Id, id, request.Name, request.Email); + await _studentService.UpdateInfo(id, request.Name, request.Email); return Ok(); } @@ -177,8 +162,6 @@ public async Task UpdateStudentInfo([FromRoute] string id, [FromB [Authorize(Policy = "RequireManageStudents")] public async Task ResetStudentPassword([FromRoute] string id, [FromBody] ResetStudentPasswordRequestDTO request) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -186,7 +169,7 @@ public async Task ResetStudentPassword([FromRoute] string id, [Fr try { - await _studentService.ResetPassword(tenant!.Id, id, request.AutogeneratePassword, request.Password, request.SendInstructions); + await _studentService.ResetPassword(id, request.AutogeneratePassword, request.Password, request.SendInstructions); return Ok(); } @@ -208,8 +191,6 @@ public async Task ResetStudentPassword([FromRoute] string id, [Fr [Authorize(Policy = "RequireManageStudents")] public async Task DeleteStudent([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -217,7 +198,7 @@ public async Task DeleteStudent([FromRoute] string id) try { - await _studentService.DeleteStudent(tenant!.Id, id); + await _studentService.DeleteStudent(id); return Ok(); } @@ -235,16 +216,9 @@ public async Task DeleteStudent([FromRoute] string id) [Authorize(Policy = "RequireManageStudents")] public async Task GetStudentClasses([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - try { - var classes = await _studentService.GetStudentClasses(tenant!.Id, id); - - if (tenant.Type == TenantType.SINGLE_CLASS) - { - return Ok(classes.FirstOrDefault()?.Id ?? string.Empty); - } + var classes = await _studentService.GetStudentClasses(id); return Ok(classes); } diff --git a/backend/Api/Controllers/TenantController.cs b/backend/Api/Controllers/TenantController.cs deleted file mode 100644 index bb98cdca..00000000 --- a/backend/Api/Controllers/TenantController.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Api.Filters; -using Application.DTOs.Tenant; -using Application.Services; -using Domain.Exceptions; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System.Security.Authentication; -using System.Security.Claims; - -namespace Api.Controllers; - -[Route("tenant")] -[ApiController] -public class TenantController(TenantService tenantService, BillingService billingService) : ControllerBase -{ - public readonly TenantService _tenantService = tenantService; - public readonly BillingService _billingService = billingService; - - [Authorize(Policy = "RequireStaff")] - [HttpPut("single")] - public async Task CreateSingleClass([FromBody] SingleClassCreationDTO request) - { - var tenantClaim = User.FindFirst("tenant")?.Value; - if (!string.IsNullOrEmpty(tenantClaim) && tenantClaim.Trim() != "{}") - return Forbid(); - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - await _tenantService.CreateSingleClassTenant( - User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, - request.Name, - request.Plan, - request.Payment.CardNumber, - request.Payment.Expiry, - request.Payment.Cvv, - request.Payment.CardHolderName - ); - - return Ok(); - } - catch (PaymentFailedException) - { - return BadRequest(); - } - } - - [Authorize(Policy = "RequireStaff")] - [HttpPut("organization")] - public async Task CreateOrganization([FromBody] OrganizationCreationDTO request) - { - var tenantClaim = User.FindFirst("tenant")?.Value; - if (!string.IsNullOrEmpty(tenantClaim) && tenantClaim.Trim() != "{}") - return Forbid(); - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - await _tenantService.CreateOrganizationTenant( - User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, - request.Name, - request.Plan, - request.Payment.CardNumber, - request.Payment.Expiry, - request.Payment.Cvv, - request.Payment.CardHolderName - ); - - return Ok(); - } - catch (PaymentFailedException) - { - return BadRequest(); - } - } - - [HttpDelete] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageTenant")] - public async Task DeleteTenant([FromBody] TenantDeletionDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - await _tenantService.DeleteTenant(User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, tenant!.Id, request.Password); - - return Ok(); - } - catch(InvalidCredentialException) - { - return Unauthorized(); - } - catch - { - return Forbid(); - } - } - - [HttpPost] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageTenant")] - public async Task UpdateTenant([FromBody] TenantUpdateDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - try - { - await _tenantService.UpdateTenant(tenant!, request.Name); - - return Ok(); - } - catch(TenantNotFoundException) - { - return Forbid(); - } - } - - [HttpGet("billing")] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageBilling")] - public async Task GetBillingInfo() - { - var tenantToken = HttpContext.Items["Tenant"] as TokenTenantDTO; - var tenant = await _tenantService.GetTenantById(tenantToken!.Id); - - return Ok(new - { - plan = tenant?.Plan.ToString(), - status = tenant?.SubscriptionStatus, - nextBillingDate = tenant?.NextBillingDate, - cardBrand = tenant?.CardBrand, - last4 = tenant?.Last4, - cardExpiry = tenant?.CardExpiry, - cardHolderName = tenant?.CardHolderName - }); - } - - [HttpPost("billing/card")] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageBilling")] - public async Task UpdateCard([FromBody] PaymentDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - - try - { - await _billingService.UpdateCard( - tenant!.Id, - User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, - request.CardNumber, - request.Expiry, - request.Cvv, - request.CardHolderName - ); - return Ok(); - } - catch (PaymentFailedException) - { - return BadRequest(); - } - } - - [HttpPost("billing/cancel")] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageBilling")] - public async Task CancelSubscription() - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - await _billingService.CancelSubscription(tenant!.Id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!); - return Ok(); - } - - [HttpPost("billing/plan")] - [ServiceFilter(typeof(TenantFilter))] - [Authorize(Policy = "RequireStaff")] - [Authorize(Policy = "RequireManageBilling")] - public async Task ChangePlan([FromBody] ChangePlanDTO request) - { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - await _billingService.ChangePlan(tenant!.Id, User.FindFirst(ClaimTypes.NameIdentifier)?.Value!, request.Plan); - return Ok(); - } -} diff --git a/backend/Api/Controllers/UserController.cs b/backend/Api/Controllers/UserController.cs index 1fc5ba58..76bb80a8 100644 --- a/backend/Api/Controllers/UserController.cs +++ b/backend/Api/Controllers/UserController.cs @@ -1,5 +1,3 @@ -using Api.Filters; -using Application.DTOs.Tenant; using Application.DTOs.User; using Application.Services; using Domain.Enums; @@ -12,7 +10,6 @@ namespace Api.Controllers; [Route("users")] [ApiController] -[ServiceFilter(typeof(TenantFilter))] public class UserController(UserService userService) : ControllerBase { public readonly UserService _userService = userService; @@ -21,12 +18,9 @@ public class UserController(UserService userService) : ControllerBase [Authorize] public async Task GetUserProfile([FromRoute] string id) { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; - try { - var user = await _userService.GetUserProfile(tenant!.Id, id); - + var user = await _userService.GetUserProfile(id); return Ok(user); } catch (UserNotFoundException) @@ -56,7 +50,6 @@ public async Task SetSelfProfile([FromBody] UpdateProfileRequestDT [Authorize] public async Task GetMyClasses() { - var tenant = HttpContext.Items["Tenant"] as TokenTenantDTO; var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (string.IsNullOrEmpty(userId)) @@ -66,13 +59,7 @@ public async Task GetMyClasses() try { - var classes = await _userService.GetUserClasses(userId, tenant!.Id); - - if (tenant.Type == TenantType.SINGLE_CLASS) - { - return Ok(classes.FirstOrDefault()?.Id ?? string.Empty); - } - + var classes = await _userService.GetUserClasses(userId); return Ok(classes); } catch (UserNotFoundException) diff --git a/backend/Api/Filters/TenantFilter.cs b/backend/Api/Filters/TenantFilter.cs deleted file mode 100644 index 7744d4e3..00000000 --- a/backend/Api/Filters/TenantFilter.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Application.DTOs.Tenant; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Api.Filters; -public class TenantFilter : IAsyncActionFilter -{ - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - var tenantClaim = context.HttpContext.User.FindFirst("tenant")?.Value; - - if (string.IsNullOrWhiteSpace(tenantClaim) || tenantClaim.Trim() == "{}") - { - context.Result = new ForbidResult(); - return; - } - - var tenant = JsonSerializer.Deserialize( - tenantClaim, - new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - } - ); - - context.HttpContext.Items["Tenant"] = tenant!; - - await next(); - } -} diff --git a/backend/Api/Middleware/ExceptionMiddleware.cs b/backend/Api/Middleware/ExceptionMiddleware.cs index 3874a01f..1389e1c1 100644 --- a/backend/Api/Middleware/ExceptionMiddleware.cs +++ b/backend/Api/Middleware/ExceptionMiddleware.cs @@ -27,14 +27,7 @@ private async Task HandleExceptionAsync(HttpContext context, Exception exception switch (exception) { - case InvalidTenantException: - code = HttpStatusCode.Forbidden; - message = exception.Message; - break; - case PlanLimitReachedException: - code = HttpStatusCode.Forbidden; - message = exception.Message; - break; + case InvalidClassException: code = HttpStatusCode.NotFound; message = exception.Message; diff --git a/backend/Api/Program.cs b/backend/Api/Program.cs deleted file mode 100644 index b189fef1..00000000 --- a/backend/Api/Program.cs +++ /dev/null @@ -1,207 +0,0 @@ -using Amazon.S3; -using Api.Authorization; -using Api.Middleware; -using Api.Filters; -using Application.Services; -using Data; -using Data.Repositories; -using Domain.Enums; -using Domain.Interfaces; -using Infrastructure; -using Microsoft.AspNetCore.Authorization; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; -using Microsoft.OpenApi.Models; -using System.Text; - -var builder = WebApplication.CreateBuilder(args); - -var conn = builder.Configuration.GetConnectionString("DefaultConnection"); - -builder.Services.AddControllers(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => -{ - options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", - Name = "Authorization", - In = ParameterLocation.Header, - Type = SecuritySchemeType.Http, - Scheme = "Bearer" - }); - - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer" - } - }, - Array.Empty() - } - }); -}); -builder.Services.AddDbContext(options => options.UseMySql(conn, ServerVersion.AutoDetect(conn))); - -builder.Configuration.AddJsonFile("plans.config.json", optional: false, reloadOnChange: true); -builder.Services.Configure(builder.Configuration); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddScoped(); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -builder.Services.AddScoped(sp => -{ - var config = sp.GetRequiredService(); - var secret = config["Jwt:Secret"]; - return new UserJwtProvider(secret!); -}); - -builder.Services.AddScoped(sp => -{ - var config = sp.GetRequiredService(); - var secret = config["Firebase:ServiceAccount"]; - return new NotificationProvider(secret!); -}); - -builder.Services.AddScoped(sp => -{ - var config = sp.GetRequiredService(); - var discordUrl = config["DiscordWebhook"]; - var discordProvider = new DiscordWebhookProvider(discordUrl!); - - var gmailFrom = config["GmailSmtp:FromAddress"] ?? ""; - var gmailUsername = config["GmailSmtp:Username"] ?? ""; - var gmailPassword = config["GmailSmtp:Password"] ?? ""; - var gmailProvider = new GmailSmtpProvider(gmailFrom, gmailUsername, gmailPassword); - - return new CompositeEmailProvider([discordProvider, gmailProvider]); -}); - -builder.Services.AddScoped(sp => -{ - var config = sp.GetRequiredService(); - var apiUrl = config["Ollama:Url"] ?? throw new InvalidOperationException("Ollama:Url is not configured."); - var apiKey = config["Ollama:ApiKey"] ?? ""; - var modelName = config["Ollama:Model"] ?? "gemma4"; - return new OllamaProvider(apiUrl, apiKey, modelName); -}); - -builder.Services.AddScoped(); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => new AmazonS3Client( - sp.GetRequiredService()["AWS:AccessKey"], - sp.GetRequiredService()["AWS:SecretKey"], - Amazon.RegionEndpoint.GetBySystemName(sp.GetRequiredService()["AWS:Region"]) -)); -builder.Services.AddScoped(provider => -{ - var s3Client = provider.GetRequiredService(); - - return new S3Storage(s3Client, provider.GetRequiredService()["AWS:BucketName"]!); -}); - -builder.Services.AddCors(options => -{ - options.AddPolicy("AllowAll", corsPolicyBuilder => corsPolicyBuilder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); -}); - -builder.Services.AddAuthentication("JwtBearer") - .AddJwtBearer("JwtBearer", options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = "yaspo.twoaxis.org", - - ValidateAudience = true, - ValidAudience = "yaspo.twoaxis.org", - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!) - ) - }; - }); - -builder.Services.AddAuthorizationBuilder() - .AddPolicy("RequireStaff", policy => policy.RequireClaim("type", "STAFF")) - .AddPolicy("RequireStudent", policy => policy.RequireClaim("type", "STUDENT")) - .AddPolicy("RequireManageTenant", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_TENANT))) - .AddPolicy("RequireManageStudents", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_STUDENTS))) - .AddPolicy("RequireManageStaff", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_STAFF))) - .AddPolicy("RequireEducator", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.EDUCATOR))) - .AddPolicy("RequireManageSelfProfile", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_SELF_PROFILE))) - .AddPolicy("RequireManageClasses", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_CLASSES))) - .AddPolicy("RequireManageBilling", policy => policy.Requirements.Add(new PermissionRequirement(PermissionType.MANAGE_BILLING))) - .AddPolicy("RequireViewAllClasses", policy => policy.RequireAssertion(context => - { - var permissions = context.User - .FindAll("permissions") - .Select(p => p.Value); - - return permissions.Contains(PermissionType.MANAGE_CLASSES.ToString()) || - permissions.Contains(PermissionType.MANAGE_STUDENTS.ToString()) || - permissions.Contains(PermissionType.MANAGE_STAFF.ToString()); - })) - .AddPolicy("RequireStudentOrEducator", policy => - policy.RequireAssertion(context => - { - if (context.User.HasClaim("type", "STUDENT")) - return true; - - var permissions = context.User - .FindAll("permissions") - .Select(p => p.Value); - - return permissions.Contains( - PermissionType.EDUCATOR.ToString()); - })); // TODO: Refactor, this is not clean. - -var app = builder.Build(); - -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -app.UseCors("AllowAll"); -app.UseMiddleware(); -app.UseAuthentication(); -app.UseAuthorization(); - -app.MapControllers(); -app.Run(); diff --git a/backend/Api/appsettings.Development.json b/backend/Api/appsettings.Development.json index c708d23d..2bb8f8db 100644 --- a/backend/Api/appsettings.Development.json +++ b/backend/Api/appsettings.Development.json @@ -11,4 +11,4 @@ "Jwt": { "Secret": "test_test_test_test_test_test_test_test_test_test_test_test" } -} +} \ No newline at end of file diff --git a/backend/Api/appsettings.json b/backend/Api/appsettings.json index 05567592..fdc0ede7 100644 --- a/backend/Api/appsettings.json +++ b/backend/Api/appsettings.json @@ -1,34 +1,34 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "LiveKit": { - "ApiKey": "my_key", - "ApiSecret": "135135135135135135135135135135135135135135780913750871389571908375908173059871390857190387590183750981375908713905871390571903759081375890", - "Url": "http://192.168.1.8:7880" - }, - "DiscordWebhook": "https://discord.com/api/webhooks/1424138993427025922/KISsDXSVhRaOtjNvFN3FXwS_1WTOvFpuUA7LzUZxyYcG0-_mA_yPUNOXq4-96ih7Vqip", - "Firebase": { - "ServiceAccount": "{\n \"type\": \"service_account\",\n \"project_id\": \"twoaxis-yaspo\",\n \"private_key_id\": \"4c06954b742ed1b2adb1ea44949b5a00ef0b31b5\",\n \"private_key\": \"-----BEGIN PRIVATE KEY-----\\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDFI2vMaNmv0p/e\\n2FPq6ILZvMnSe5uFzawGtvikNbOXblQsBCrdjMBOlzaElP6d1jPDoXm+Sp5mRVhd\\nlF+uHZVaknkdtQnkdgom+ye6/e7wsgtSx12sfELmJetARmPDUpRPlh2+dmBjMPyX\\nN6vIwk1suwbhhXcEegqEOghzb1HeTLqwpje6QqqPhhmcOoi1OehBJDPWpevforaY\\nigH1OJfWSu1H5/SiSpJuu0lmkyzOvqt8XIKSj4caBQHAaA/mfVST0tHhXNTuL/0v\\nuXPrE/mVQVm9zJl9tsl2cOrfBstGDkGV21QvhVxODiFpELUXeOMyD4OOnesKU2lD\\nHFQDVH6TAgMBAAECggEAJGvi4qUMcttrBfTZrDDA3BFxCB4mIZZaDfOIBG6Xe4a6\\nrlcmxD/Y16xAOkZ8tBR7A821fF603Rg6ty9ObhCxI0akccScy0Szo3gLYxS/LGD4\\njpaey9s5UOP4jqtyiweatwl4/VNJg4ntYNK8Ph8HxurW3puV3ZHel4dRQGJ0sakP\\nraRUoeDH8VAyixFMXXjvIcUVmNsOMOsCGs4sJrhDlLwT6gn9agBcygPWD6K5v2zo\\nKxR0l/m6yAsjoDeqgQfYIju9cdrFArEbYEnXRjm5VG1Qjb6blS9LR+AfDQVx9xrz\\n+7vlEpaQHxsXx9qksDVbDCZ+NnNi1Vsa2rlMsUDOYQKBgQD/61j2tC6pz9zXJ0Eu\\nLRHQfjQwIThA7nPzLJ1sSSgBuACayC9qt/gy6KO0it2crOakT9DmdiPLocl4qp86\\nRAYg3TLN4h+DlbIzT1fJ4VVSZIDaK9mUgyu9ZMUIz5oSA4ldTCxP75OHJi/k65HX\\nyUGxuSVwyP/z5b2E+/vLlHwHnQKBgQDFM1R6p8ARnmvc/GCg1lvA7ukXy73cwXVm\\n3HvfOybzCsLokd0V4elcLNrxDZcWeNAAk+EegyB9RYiAz4UehWhgk8iznqdPievb\\nm23vuWFLd/2J6GZZDD4TnQ8b4isIUrzQL/nuNWF+xbJIRJyfVqEtjNc/k0NG4Piq\\n5jRAJ4T/7wKBgFxsLjDKWZ/SBbtd3dhDRo+5RDeth5dLhmxnzGJy+RPF09ISQa2U\\nyvn3skddbzNJ7fGhMZxJRhwgTZZaaC6uqlfsCpD3/NGUmkqVeOrbiei7ix0/gNB1\\nnrJDVjUcZFPlGxKqk8Y6i1jfnaRavszjTepVJwRYUrdOXOdN6V1YPoKxAoGAHnXG\\nR8ffkBkjWhgVqOJTe+KrWISdCBtN0hyeeaW+Z24zGw9k0ft6O0Wmb/OEcfEpV91/\\nuWHRw+fYpOTpEATVUY2iQ7Ru7YdFSj+g5JWC5NBc9k9Sd9C/SfJJoin2MavuBE5U\\n0amnLJjG6AjJFIeiD9IgcW6CxtD7OBKk4X/5ODsCgYBf8j8CQevEcQTt2tCMfk7t\\ncuVGrOAkiUbIshDBojJjaYAGd5YRpmVyi6eSuAUzIwmdpB6JFGkoue/xufgV17v/\\n3ks6Qovt6ObNrWLlKlHGV5m8btQNS4L5P4FQVGW5+r7VXc/dKK0S6xPiijUUn6x7\\nspneKUMPLyGMX11I4R9Rlw==\\n-----END PRIVATE KEY-----\\n\",\n \"client_email\": \"firebase-adminsdk-fbsvc@twoaxis-yaspo.iam.gserviceaccount.com\",\n \"client_id\": \"109655795699936403272\",\n \"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\",\n \"token_uri\": \"https://oauth2.googleapis.com/token\",\n \"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\",\n \"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40twoaxis-yaspo.iam.gserviceaccount.com\",\n \"universe_domain\": \"googleapis.com\"\n}\n" - }, - "AWS": { - "AccessKey": "AKIA4XYMC6GGWDYJLOHO", - "SecretKey": "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", - "Region": "eu-central-1", - "BucketName": "yaspo-staging-bucket" - }, - "Ollama": { - "Url": "https://ollama.com/api/chat", - "ApiKey": "API_KEY", - "Model": "gemma4:31b-cloud" - }, - "GmailSmtp": { - "FromAddress": "no-reply@example.com", - "Username": "your-gmail-username@gmail.com", - "Password": "your-app-password" - } + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "LiveKit": { + "ApiKey": "YOUR_LIVEKIT_API_KEY", + "ApiSecret": "YOUR_LIVEKIT_API_SECRET", + "Url": "http://192.168.1.8:7880" + }, + "DiscordWebhook": "YOUR_DISCORD_WEBHOOK_URL", + "Firebase": { + "ServiceAccount": "YOUR_FIREBASE_SERVICE_ACCOUNT_JSON_STRING" + }, + "AWS": { + "AccessKey": "YOUR_AWS_ACCESS_KEY", + "SecretKey": "YOUR_AWS_SECRET_KEY", + "Region": "eu-central-1", + "BucketName": "your-s3-bucket-name" + }, + "Ollama": { + "Url": "https://ollama.com/api/chat", + "ApiKey": "YOUR_OLLAMA_API_KEY", + "Model": "gemma4:31b-cloud" + }, + "GmailSmtp": { + "FromAddress": "no-reply@example.com", + "Username": "your-gmail-username@gmail.com", + "Password": "YOUR_GMAIL_APP_PASSWORD" + } } \ No newline at end of file diff --git a/backend/Application.Tests/Services/AnnouncementServiceTests.cs b/backend/Application.Tests/Services/AnnouncementServiceTests.cs index 5320bd80..c8582b55 100644 --- a/backend/Application.Tests/Services/AnnouncementServiceTests.cs +++ b/backend/Application.Tests/Services/AnnouncementServiceTests.cs @@ -3,7 +3,6 @@ using Domain.Entities; using Domain.Enums; using Domain.Exceptions; -using Application.DTOs.Tenant; using Domain.Interfaces; using Moq; using System.Runtime.InteropServices.Marshalling; diff --git a/backend/Application.Tests/Services/AssignmentServiceTests.cs b/backend/Application.Tests/Services/AssignmentServiceTests.cs index 21cf98ac..937d3551 100644 --- a/backend/Application.Tests/Services/AssignmentServiceTests.cs +++ b/backend/Application.Tests/Services/AssignmentServiceTests.cs @@ -3,7 +3,6 @@ using Domain.Entities; using Domain.Enums; using Domain.Exceptions; -using Application.DTOs.Tenant; using Domain.Interfaces; using Moq; diff --git a/backend/Application.Tests/Services/GradeSheetServiceTests.cs b/backend/Application.Tests/Services/GradeSheetServiceTests.cs index 03f76d34..43e4b854 100644 --- a/backend/Application.Tests/Services/GradeSheetServiceTests.cs +++ b/backend/Application.Tests/Services/GradeSheetServiceTests.cs @@ -3,7 +3,6 @@ using Domain.Entities; using Domain.Enums; using Domain.Exceptions; -using Application.DTOs.Tenant; using Domain.Interfaces; using Moq; using Xunit.Sdk; diff --git a/backend/Application.Tests/Services/QuizServiceTests.cs b/backend/Application.Tests/Services/QuizServiceTests.cs index 8033a2a5..9adb6da4 100644 --- a/backend/Application.Tests/Services/QuizServiceTests.cs +++ b/backend/Application.Tests/Services/QuizServiceTests.cs @@ -3,7 +3,6 @@ using Domain.Entities; using Domain.Enums; using Domain.Exceptions; -using Application.DTOs.Tenant; using Domain.Interfaces; using Moq; diff --git a/backend/Application.Tests/Services/TenantServiceTests.cs b/backend/Application.Tests/Services/TenantServiceTests.cs deleted file mode 100644 index 21cfa2b5..00000000 --- a/backend/Application.Tests/Services/TenantServiceTests.cs +++ /dev/null @@ -1,193 +0,0 @@ - -using Application.Services; -using Domain.Entities; -using Domain.Enums; -using Domain.Exceptions; -using Domain.Interfaces; -using Moq; - -namespace Application.Tests.Services; - -public class TenantServiceTests -{ - private readonly Mock _mockTenantRepository = new(); - private readonly Mock _mockUserRepository = new(); - private readonly Mock _mockClassRepository = new(); - private readonly Mock _mockEmailProvider = new(); - - private readonly TenantService _tenantService; - - public TenantServiceTests() - { - _tenantService = new TenantService( - _mockTenantRepository.Object, - _mockUserRepository.Object, - _mockClassRepository.Object, - _mockEmailProvider.Object - ); - } - - [Fact] - public async Task CreateSingleClassTenant_Success() - { - string testUuid = "test-uuid"; - string testName = "test name"; - string testPlan = "BASIC"; - _mockUserRepository.Setup(r => r.GetUserById(testUuid)).ReturnsAsync(new User { Email = "test@test.com" }); - - await _tenantService.CreateSingleClassTenant(testUuid, testName, testPlan, "1234123412341234", "06/2026", "123", "Mohamed Eldahtoury"); - - _mockTenantRepository.Verify(r => r.CreateTenant( - It.IsAny(), - testName, - TenantType.SINGLE_CLASS, - SubscriptionPlan.BASIC, - "ACTIVE", - It.IsAny(), - "Visa", - "1234", - "06/2026", - "Mohamed Eldahtoury"), - Times.Once); - - _mockClassRepository.Verify(r => r.CreateClass( - It.IsAny(), - testName, - It.IsAny()), - Times.Once); - - _mockUserRepository.Verify(r => r.SetUserTenant( - testUuid, It.IsAny()), - Times.Once); - } - - [Fact] - public async Task CreateOrganizationTenant_Success() - { - string testUuid = "test-uuid"; - string testName = "test name"; - string testPlan = "STARTER"; - _mockUserRepository.Setup(r => r.GetUserById(testUuid)).ReturnsAsync(new User { Email = "test@test.com" }); - - await _tenantService.CreateOrganizationTenant(testUuid, testName, testPlan, "1234123412341234", "06/2026", "123", "Mohamed Eldahtoury"); - - _mockTenantRepository.Verify(r => r.CreateTenant( - It.IsAny(), - testName, - TenantType.ORGANIZATION, - SubscriptionPlan.STARTER, - "ACTIVE", - It.IsAny(), - "Visa", - "1234", - "06/2026", - "Mohamed Eldahtoury"), - Times.Once); - - _mockUserRepository.Verify(r => r.SetUserTenant( - testUuid, It.IsAny()), - Times.Once); - } - - [Fact] - public async Task DeleteTenant_ShouldThrowAnExceptionWhenUserNotFound() - { - string testUuid = "test-uuid"; - string testTenantId = "test-tenant"; - - _mockUserRepository.Setup(r => r.GetUserById(testUuid)) - .ReturnsAsync((User?)null); - - await Assert.ThrowsAsync( - () => _tenantService.DeleteTenant(testUuid, testTenantId, "any_password")); - } - - [Fact] - public async Task DeleteTenant_ShouldThrowAnExceptionWhenTenantNotFound() - { - string testEmail = "test@example.com"; - string testUuid = "test-uuid"; - string testTenantId = "test-tenant"; - - _mockUserRepository.Setup(r => r.GetUserById(testUuid)) - .ReturnsAsync(new User("id", "Existing User", testEmail, "hashedpassword", UserType.STAFF, null, DateTime.UtcNow, [])); - - _mockTenantRepository.Setup(r => r.GetTenantById(testTenantId)) - .ReturnsAsync((Tenant?) null); - - await Assert.ThrowsAsync( - () => _tenantService.DeleteTenant(testUuid, testTenantId, "any_password")); - } - - [Fact] - public async Task DeleteTenant_ShouldThrowAnExceptionWhenIncorrectPassword() - { - string testEmail = "test@example.com"; - string testUuid = "test-uuid"; - string testTenantId = "test-tenant"; - string hashedPassword = BCrypt.Net.BCrypt.HashPassword("test_password"); - - _mockUserRepository.Setup(r => r.GetUserById(testUuid)) - .ReturnsAsync(new User("id", "Existing User", testEmail, hashedPassword, UserType.STAFF, null, DateTime.UtcNow, [])); - - _mockTenantRepository.Setup(r => r.GetTenantById(testTenantId)) - .ReturnsAsync(new Tenant(testTenantId, "Math", TenantType.SINGLE_CLASS, SubscriptionPlan.BASIC, "ACTIVE", DateTime.UtcNow.AddMonths(1))); - - await Assert.ThrowsAsync( - () => _tenantService.DeleteTenant(testUuid, testTenantId, "any_password")); - } - - [Fact] - public async Task DeleteTenant_Success() - { - string testEmail = "test@example.com"; - string testUuid = "test-uuid"; - string testTenantId = "test-tenant"; - string hashedPassword = BCrypt.Net.BCrypt.HashPassword("test_password"); - - _mockUserRepository.Setup(r => r.GetUserById(testUuid)) - .ReturnsAsync(new User("id", "Existing User", testEmail, hashedPassword, UserType.STAFF, null, DateTime.UtcNow, [])); - - _mockTenantRepository.Setup(r => r.GetTenantById(testTenantId)) - .ReturnsAsync(new Tenant(testTenantId, "Math", TenantType.SINGLE_CLASS, SubscriptionPlan.BASIC, "ACTIVE", DateTime.UtcNow.AddMonths(1))); - - _mockClassRepository.Setup(r => r.GetAllClassesByTenantId(testTenantId)).ReturnsAsync([ - new Class("class_id", "class name", testTenantId) - ]); - - await _tenantService.DeleteTenant(testUuid, testTenantId, "test_password"); - - _mockClassRepository.Verify(r => r.DeleteClassesByTenantId(testTenantId), Times.Once); - _mockUserRepository.Verify(r => r.DeleteUsersByTenantId(testTenantId), Times.Once); - _mockTenantRepository.Verify(r => r.DeleteTenant(testTenantId), Times.Once); - } - - [Fact] - public async Task DeleteOrganizationTenant_Success() - { - string testEmail = "test@example.com"; - string testUuid = "test-uuid"; - string testTenantId = "test-tenant-org"; - string hashedPassword = BCrypt.Net.BCrypt.HashPassword("test_password"); - - _mockUserRepository.Setup(r => r.GetUserById(testUuid)) - .ReturnsAsync(new User("id", "Existing User", testEmail, hashedPassword, UserType.STAFF, null, DateTime.UtcNow, [])); - - _mockTenantRepository.Setup(r => r.GetTenantById(testTenantId)) - .ReturnsAsync(new Tenant(testTenantId, "OrgName", TenantType.ORGANIZATION, SubscriptionPlan.STARTER, "ACTIVE", DateTime.UtcNow.AddMonths(1))); - - _mockClassRepository.Setup(r => r.GetAllClassesByTenantId(testTenantId)).ReturnsAsync([ - new Class("class_id_1", "class name 1", testTenantId), - new Class("class_id_2", "class name 2", testTenantId) - ]); - - await _tenantService.DeleteTenant(testUuid, testTenantId, "test_password"); - - _mockUserRepository.Verify(r => r.RemoveUserFromClass(testUuid, "class_id_1"), Times.Once); - _mockUserRepository.Verify(r => r.RemoveUserFromClass(testUuid, "class_id_2"), Times.Once); - - _mockClassRepository.Verify(r => r.DeleteClassesByTenantId(testTenantId), Times.Once); - _mockUserRepository.Verify(r => r.DeleteUsersByTenantId(testTenantId), Times.Once); - _mockTenantRepository.Verify(r => r.DeleteTenant(testTenantId), Times.Once); - } -} diff --git a/backend/Application/DTOs/Tenant/ChangePlanDTO.cs b/backend/Application/DTOs/Tenant/ChangePlanDTO.cs deleted file mode 100644 index 7309c507..00000000 --- a/backend/Application/DTOs/Tenant/ChangePlanDTO.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class ChangePlanDTO -{ - [Required] - [JsonPropertyName("plan")] - public string Plan { get; set; } = string.Empty; -} diff --git a/backend/Application/DTOs/Tenant/OrganizationCreationDTO.cs b/backend/Application/DTOs/Tenant/OrganizationCreationDTO.cs deleted file mode 100644 index e7f12dcb..00000000 --- a/backend/Application/DTOs/Tenant/OrganizationCreationDTO.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class OrganizationCreationDTO -{ - [Required] - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("plan")] - public string Plan { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("payment")] - public PaymentDTO Payment { get; set; } = null!; -} diff --git a/backend/Application/DTOs/Tenant/PaymentDTO.cs b/backend/Application/DTOs/Tenant/PaymentDTO.cs deleted file mode 100644 index bffa5178..00000000 --- a/backend/Application/DTOs/Tenant/PaymentDTO.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class PaymentDTO -{ - [Required] - [JsonPropertyName("cardNumber")] - public string CardNumber { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("expiry")] - public string Expiry { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("cvv")] - public string Cvv { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("cardHolderName")] - public string CardHolderName { get; set; } = string.Empty; -} diff --git a/backend/Application/DTOs/Tenant/SingleClassCreationDTO.cs b/backend/Application/DTOs/Tenant/SingleClassCreationDTO.cs deleted file mode 100644 index abd82e02..00000000 --- a/backend/Application/DTOs/Tenant/SingleClassCreationDTO.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class SingleClassCreationDTO -{ - [Required] - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("plan")] - public string Plan { get; set; } = string.Empty; - - [Required] - [JsonPropertyName("payment")] - public PaymentDTO Payment { get; set; } = null!; -} diff --git a/backend/Application/DTOs/Tenant/TenantDeletionDTO.cs b/backend/Application/DTOs/Tenant/TenantDeletionDTO.cs deleted file mode 100644 index e3daa4c8..00000000 --- a/backend/Application/DTOs/Tenant/TenantDeletionDTO.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class TenantDeletionDTO -{ - [JsonPropertyName("password")] - [MinLength(6)] - public string Password { get; set; } = string.Empty; -} diff --git a/backend/Application/DTOs/Tenant/TenantUpdateDTO.cs b/backend/Application/DTOs/Tenant/TenantUpdateDTO.cs deleted file mode 100644 index bb49c122..00000000 --- a/backend/Application/DTOs/Tenant/TenantUpdateDTO.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Application.DTOs.Tenant; - -public class TenantUpdateDTO -{ - [Required] - public string Name { get; set; } = string.Empty; -} diff --git a/backend/Application/DTOs/Tenant/TokenTenantDTO.cs b/backend/Application/DTOs/Tenant/TokenTenantDTO.cs deleted file mode 100644 index a64ec72e..00000000 --- a/backend/Application/DTOs/Tenant/TokenTenantDTO.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Domain.Enums; -using System.Text.Json.Serialization; - -namespace Application.DTOs.Tenant; - -public class TokenTenantDTO -{ - [JsonPropertyName("id")] - public string Id { get; set; } = string.Empty; - - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public TenantType Type { get; set; } - - [JsonPropertyName("plan")] - public string? Plan { get; set; } - -} diff --git a/backend/Application/Services/AnnouncementService.cs b/backend/Application/Services/AnnouncementService.cs index ad170df7..d1e43cdf 100644 --- a/backend/Application/Services/AnnouncementService.cs +++ b/backend/Application/Services/AnnouncementService.cs @@ -1,6 +1,5 @@  using Application.DTOs.Announcements; -using Application.DTOs.Tenant; using Domain.Entities; using Domain.Exceptions; using Domain.Interfaces; @@ -13,24 +12,24 @@ public class AnnouncementService(IAnnouncementRepository announcementRepository, private readonly IFcmTokenRepository _fcmRepository = fcmRepository; private readonly INotificationProvider _notificationProvider = notificationProvider; - public async Task CreateAnnouncement(TokenTenantDTO tenant, string classId, string content, string authorId, string authorName) + public async Task CreateAnnouncement(string classId, string content, string authorId, string authorName) { var uuid = Guid.NewGuid().ToString(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, authorId); + var resolvedClassId = await ResolveClassIdForUser(classId, authorId); await _announcementRepository.CreateAnnouncement(uuid, resolvedClassId, content, authorId, DateTime.UtcNow); var tokens = await _fcmRepository.GetStudentsTokensByClassId(resolvedClassId); - var _class = await _classRepository.GetClassById(resolvedClassId, tenant.Id); + var _class = await _classRepository.GetClassById(resolvedClassId); await _notificationProvider.SendNotifications(tokens, _class!.Name, $"{authorName}: {content}"); return uuid; } - public async Task> GetAnnouncements(TokenTenantDTO tenant, string classId, string authorId) + public async Task> GetAnnouncements(string classId, string authorId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, authorId); + var resolvedClassId = await ResolveClassIdForUser(classId, authorId); var announcements = await _announcementRepository.GetAnnouncementsByClassId(resolvedClassId); List list = []; @@ -49,9 +48,9 @@ public async Task> GetAnnouncements(TokenTenantDTO tenant, return list; } - public async Task DeleteAnnouncement(TokenTenantDTO tenant, string classId, string announcementId, string userId) + public async Task DeleteAnnouncement(string classId, string announcementId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var announcement = await _announcementRepository.GetAnnouncementById(announcementId) ?? throw new AnnouncementNotFoundException(); if(announcement.ClassId != resolvedClassId) diff --git a/backend/Application/Services/AssignmentService.cs b/backend/Application/Services/AssignmentService.cs index 21375268..84d01e61 100644 --- a/backend/Application/Services/AssignmentService.cs +++ b/backend/Application/Services/AssignmentService.cs @@ -3,7 +3,6 @@ using Domain.Enums; using Domain.Exceptions; using Domain.Interfaces; -using Application.DTOs.Tenant; namespace Application.Services; @@ -15,9 +14,9 @@ public class AssignmentService(IAssignmentRepository assignmentRepository, IClas private readonly INotificationProvider _notificationProvider = notificationProvider; private readonly IS3Storage s3Storage = s3Storage; - public async Task> GetAssignments(TokenTenantDTO tenant, string classId, UserType type, string userId) + public async Task> GetAssignments(string classId, UserType type, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var assignments = await _assignmentRepository.GetAssignmentsByClassId(resolvedClassId); assignments = type == UserType.STUDENT @@ -52,13 +51,12 @@ public async Task> GetAssignments(TokenTenantDTO tenant, str return list; } - public async Task GetAssignment(TokenTenantDTO tenant, string classId, string assignmentId, UserType type, string userId) + public async Task GetAssignment(string classId, string assignmentId, UserType type, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - if (assignment.ClassId != resolvedClassId) - throw new InvalidTenantException(); + if(type == UserType.STUDENT && !assignment.IsVisible) throw new AssignmentNotFoundException(); @@ -81,43 +79,43 @@ public async Task GetAssignment(TokenTenantDTO tenant, string cla }; } - public async Task CreateAssignment(TokenTenantDTO tenant, string classId, string title, string description, int maxGrade, DateTime dueDate, string userId, bool visible) + public async Task CreateAssignment(string classId, string title, string description, int maxGrade, DateTime dueDate, string userId, bool visible) { if (dueDate.ToUniversalTime() <= DateTime.UtcNow) throw new InvalidAssignmentException(); var uuid = Guid.NewGuid().ToString(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); await _assignmentRepository.CreateAssignment(uuid, resolvedClassId, title, description, DateTime.UtcNow, maxGrade, dueDate, userId, visible); var tokens = await _fcmRepository.GetStudentsTokensByClassId(resolvedClassId); - var _class = await _classRepository.GetClassById(resolvedClassId, tenant.Id); + var _class = await _classRepository.GetClassById(resolvedClassId); await _notificationProvider.SendNotifications(tokens, _class!.Name, $"Assignment: {title}"); return uuid; } - public async Task UpdateAssignment(TokenTenantDTO tenant, string classId, string assignmentId, string title, string description, DateTime dueDate, int maxGrade, bool visible, string userId) + public async Task UpdateAssignment(string classId, string assignmentId, string title, string description, DateTime dueDate, int maxGrade, bool visible, string userId) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); if (dueDate.ToUniversalTime() <= DateTime.UtcNow && Math.Abs((dueDate.ToUniversalTime() - assignment.DueDate.ToUniversalTime()).TotalSeconds) > 1) throw new InvalidAssignmentException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); + await _assignmentRepository.UpdateAssignment(assignmentId, title, description, dueDate, maxGrade, visible); } - public async Task DeleteAssignment(TokenTenantDTO tenant, string classId, string assignmentId, string userId) + public async Task DeleteAssignment(string classId, string assignmentId, string userId) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); + await _assignmentRepository.DeleteAssignment(assignmentId); } - public async Task SubmitAssignment(TokenTenantDTO tenant, string classId, string assignmentId, string studentId, long size, string type) + public async Task SubmitAssignment(string classId, string assignmentId, string studentId, long size, string type) { var maxSize = 10 * 1024 * 1024; List allowedTypes = ["application/pdf", "application/zip"]; @@ -126,10 +124,10 @@ public async Task SubmitAssignment(TokenTenantD if (size > maxSize) throw new InvalidFileException(); var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, studentId); + var resolvedClassId = await ResolveClassIdForUser(classId, studentId); if(!assignment.IsVisible) throw new AssignmentNotFoundException(); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + if (DateTime.UtcNow > assignment.DueDate) throw new AssignmentDueDatePassedException(); var assignmentSubmission = await _assignmentRepository.GetSubmissionByUserId(assignmentId, studentId); @@ -155,12 +153,12 @@ public async Task SubmitAssignment(TokenTenantD } } - public async Task GetSubmissionFile(TokenTenantDTO tenant, string classId, string assignmentId, string submissionId, string userId, UserType type) + public async Task GetSubmissionFile(string classId, string assignmentId, string submissionId, string userId, UserType type) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + var submission = await _assignmentRepository.GetSubmissionById(assignmentId, submissionId) ?? throw new AssignmentSubmissionNotFoundException(); @@ -183,14 +181,14 @@ public async Task GetSubmissionFile(TokenTenantDTO }; } - public async Task ConfirmSubmission(TokenTenantDTO tenant, string classId, string assignmentId, string studentId) + public async Task ConfirmSubmission(string classId, string assignmentId, string studentId) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, studentId); + var resolvedClassId = await ResolveClassIdForUser(classId, studentId); if(!assignment.IsVisible) throw new AssignmentNotFoundException(); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + var assignmentSubmission = await _assignmentRepository.GetSubmissionByUserId(assignmentId, studentId) ?? throw new AssignmentSubmissionNotFoundException(); if(assignmentSubmission.FileStatus != AssignmentSubmissionFileStatus.PENDING) throw new AssignmentSubmissionAlreadyExists(); @@ -206,12 +204,12 @@ public async Task ConfirmSubmission(TokenTenantDTO tenant, string classId, strin } } - public async Task GradeSubmission(TokenTenantDTO tenant, string classId, string assignmentId, string submissionId, int grade, string userId) + public async Task GradeSubmission(string classId, string assignmentId, string submissionId, int grade, string userId) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + if (grade < 0 || grade > assignment.MaxGrade) throw new InvalidGradeException(); @@ -225,11 +223,11 @@ public async Task GradeSubmission(TokenTenantDTO tenant, string classId, string await _assignmentRepository.GradeSubmission(submissionId, grade); } - public async Task> GetAssignmentSubmissions(TokenTenantDTO tenant, string classId, string assignmentId, string userId) + public async Task> GetAssignmentSubmissions(string classId, string assignmentId, string userId) { var assignment = await _assignmentRepository.GetAssignment(assignmentId) ?? throw new AssignmentNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); - if (assignment.ClassId != resolvedClassId) throw new InvalidTenantException(); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); + var submissions = await _assignmentRepository.GetSubmissionsByAssignmentId(assignmentId); diff --git a/backend/Application/Services/AuthService.cs b/backend/Application/Services/AuthService.cs index 514bfea3..d49f1676 100644 --- a/backend/Application/Services/AuthService.cs +++ b/backend/Application/Services/AuthService.cs @@ -50,7 +50,7 @@ public async Task Signup(string name, string email, string pass new() { Type = PermissionType.EDUCATOR, UserId = uuid }, ]; - await _userRepository.CreateUser(uuid, name,email, hashedPassword, UserType.STAFF, null, DateTime.UtcNow, permissions); + await _userRepository.CreateUser(uuid, name,email, hashedPassword, UserType.STAFF, DateTime.UtcNow, permissions); var otp = RandomNumberGenerator.GetInt32(100000, 999999).ToString(); await _emailVerificationTokenRepository.CreateToken(Guid.NewGuid().ToString(), uuid, otp, DateTime.UtcNow.AddMinutes(10)); @@ -86,7 +86,7 @@ public async Task VerifySignup(string email, string otp, string if (fcmToken != null) await _fcmTokenRepository.AddToken(user.Id, fcmToken); - var jwt = _userJwtProvider.CreateToken(user.Id, user.Name, email, null, UserType.STAFF, user.Permissions); + var jwt = _userJwtProvider.CreateToken(user.Id, user.Name, email, UserType.STAFF, user.Permissions); return new AuthResponseDTO { @@ -108,7 +108,7 @@ public async Task Login(string email, string password, string? var refreshToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); await _refreshTokenRepository.AddToken(refreshToken, user.Id, DateTime.UtcNow.AddMonths(1)); - var token = _userJwtProvider.CreateToken(user.Id, user.Name, email, user.Tenant, user.Type, user.Permissions); + var token = _userJwtProvider.CreateToken(user.Id, user.Name, email, user.Type, user.Permissions); if(fcmToken != null) await _fcmTokenRepository.AddToken(user.Id, fcmToken); return new AuthResponseDTO @@ -139,7 +139,7 @@ public async Task Refresh(string refreshToken) await _refreshTokenRepository.DeleteToken(refreshToken); await _refreshTokenRepository.AddToken(newRefreshToken, user.Id, DateTime.UtcNow.AddMonths(1)); - var acccessToken = _userJwtProvider.CreateToken(user.Id, user.Name, user.Email, user.Tenant, user.Type, user.Permissions); + var acccessToken = _userJwtProvider.CreateToken(user.Id, user.Name, user.Email, user.Type, user.Permissions); return new AuthResponseDTO { diff --git a/backend/Application/Services/BaseClassService.cs b/backend/Application/Services/BaseClassService.cs index f8b3eb5f..1c4e4b65 100644 --- a/backend/Application/Services/BaseClassService.cs +++ b/backend/Application/Services/BaseClassService.cs @@ -1,4 +1,3 @@ -using Application.DTOs.Tenant; using Domain.Enums; using Domain.Exceptions; using Domain.Interfaces; @@ -10,13 +9,13 @@ public abstract class BaseClassService(IClassRepository classRepository, IUserRe protected readonly IClassRepository _classRepository = classRepository; protected readonly IUserRepository _userRepository = userRepository; - protected async Task ResolveClassId(TokenTenantDTO tenant, string classId) + protected async Task ResolveClassId(string classId) { - var classes = await _classRepository.GetAllClassesByTenantId(tenant.Id); + var classes = await _classRepository.GetAllClasses(); if (classId == "single") { - if (tenant.Type == TenantType.SINGLE_CLASS && classes.Count > 0) + if (classes.Count > 0) { return classes[0].Id; } @@ -31,16 +30,11 @@ protected async Task ResolveClassId(TokenTenantDTO tenant, string classI return classId; } - protected async Task ResolveClassIdForUser(TokenTenantDTO tenant, string classId, string userId) + protected async Task ResolveClassIdForUser(string classId, string userId) { - var resolvedClassId = await ResolveClassId(tenant, classId); + var resolvedClassId = await ResolveClassId(classId); var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - if(user.Tenant == null || user.Tenant.Id != tenant.Id) - { - throw new InvalidTenantException(); - } - if(!user.Classes.Any(c => c.Id == resolvedClassId)) { throw new InvalidClassException(); diff --git a/backend/Application/Services/BillingService.cs b/backend/Application/Services/BillingService.cs deleted file mode 100644 index e994429b..00000000 --- a/backend/Application/Services/BillingService.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Domain.Entities; -using Domain.Enums; -using Domain.Exceptions; -using Domain.Interfaces; - -namespace Application.Services; - -public class BillingService(ITenantRepository tenantRepository, IUserRepository userRepository, IEmailProvider emailProvider) -{ - private readonly ITenantRepository _tenantRepository = tenantRepository; - private readonly IUserRepository _userRepository = userRepository; - private readonly IEmailProvider _emailProvider = emailProvider; - - private void ValidateMockPayment(string cardNumber, string expiry, string cvv, string name) - { - if (cardNumber.Replace(" ", "") != "1234123412341234" || - expiry != "06/2026" || - cvv != "123" || - name != "Mohamed Eldahtoury") - { - throw new PaymentFailedException(); - } - } - - private async Task SendReceipt(string email, string subject, string plan, double amount, string last4) - { - var body = $"**Transaction ID:** {Guid.NewGuid()}\n" + - $"**Plan:** {plan}\n" + - $"**Amount:** ${amount:F2}\n" + - $"**Date:** {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC\n" + - $"**Card:** **** **** **** {last4}"; - - _emailProvider.SendEmail(email, subject, body); - } - - public async Task UpdateCard(string tenantId, string userId, string cardNumber, string expiry, string cvv, string holderName) - { - ValidateMockPayment(cardNumber, expiry, cvv, holderName); - - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - - await _tenantRepository.UpdateCard( - tenantId, - "Visa", - cardNumber.Substring(cardNumber.Length - 4), - expiry, - holderName - ); - - // Notify user about card update (mocking a $0 verification or just a notification) - _emailProvider.SendEmail(user.Email, "Payment Method Updated", $"Your payment method ending in {cardNumber.Substring(cardNumber.Length - 4)} has been updated successfully."); - } - - public async Task CancelSubscription(string tenantId, string userId) - { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - - await _tenantRepository.UpdateSubscription(tenantId, tenant.Plan!.Value, "CANCELED", tenant.NextBillingDate); - - _emailProvider.SendEmail(user.Email, "Subscription Canceled", $"Your subscription for {tenant.Name} has been canceled. It will remain active until {tenant.NextBillingDate:MMMM dd, yyyy}."); - } - - public async Task ChangePlan(string tenantId, string userId, string newPlanStr) - { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - - SubscriptionPlan newPlan; - double amount = 0; - - if (tenant.Type == TenantType.SINGLE_CLASS) - { - newPlan = newPlanStr.ToLower() switch - { - "basic" => SubscriptionPlan.BASIC, - "pro" => SubscriptionPlan.PRO, - "premium" => SubscriptionPlan.PREMIUM, - _ => throw new InvalidTenantException() - }; - amount = newPlan switch - { - SubscriptionPlan.BASIC => 9.99, - SubscriptionPlan.PRO => 19.99, - SubscriptionPlan.PREMIUM => 49.99, - _ => 0 - }; - } - else - { - newPlan = newPlanStr.ToLower() switch - { - "starter" => SubscriptionPlan.STARTER, - "growth" => SubscriptionPlan.GROWTH, - "enterprise" => SubscriptionPlan.ENTERPRISE, - "custom" => SubscriptionPlan.CUSTOM, - _ => throw new InvalidOperationException() - }; - } - - amount = newPlan switch - { - SubscriptionPlan.STARTER => 99.99, - SubscriptionPlan.GROWTH => 199.99, - SubscriptionPlan.ENTERPRISE => 499.99, - SubscriptionPlan.CUSTOM => 0.0, - _ => throw new InvalidOperationException() - }; - - await _tenantRepository.UpdateSubscription(tenantId, newPlan, "ACTIVE", DateTime.UtcNow.AddMonths(1)); - - await SendReceipt(user.Email, "Plan Changed & New Invoice", newPlan.ToString(), amount, tenant.Last4!); - } -} diff --git a/backend/Application/Services/ClassService.cs b/backend/Application/Services/ClassService.cs index 01462a12..accde2ca 100644 --- a/backend/Application/Services/ClassService.cs +++ b/backend/Application/Services/ClassService.cs @@ -7,15 +7,13 @@ namespace Application.Services; public class ClassService( - IClassRepository classRepository, - ITenantRepository tenantRepository) + IClassRepository classRepository) { private readonly IClassRepository _classRepository = classRepository; - private readonly ITenantRepository _tenantRepository = tenantRepository; - public async Task> GetClasses(string tenantId) + public async Task> GetClasses() { - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); + var classes = await _classRepository.GetAllClasses(); return [.. classes.Select(c => new ClassDTO { Id = c.Id, @@ -23,9 +21,9 @@ public async Task> GetClasses(string tenantId) })]; } - public async Task GetClassById(string id, string tenantId) + public async Task GetClassById(string id) { - var c = await _classRepository.GetClassById(id, tenantId) ?? throw new InvalidClassException(); + var c = await _classRepository.GetClassById(id) ?? throw new InvalidClassException(); return new ClassDTO { Id = c.Id, @@ -33,45 +31,22 @@ public async Task GetClassById(string id, string tenantId) }; } - public async Task CreateClass(string tenantId, string name) + public async Task CreateClass(string name) { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - - if (tenant.Type != TenantType.ORGANIZATION) - { - throw new InvalidTenantException(); - } - var id = Guid.NewGuid().ToString(); - await _classRepository.CreateClass(id, name, tenantId); + await _classRepository.CreateClass(id, name); return id; } - public async Task UpdateClass(string id, string tenantId, string name) + public async Task UpdateClass(string id, string name) { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - - if (tenant.Type != TenantType.ORGANIZATION) - { - throw new InvalidTenantException(); - } - - var _classEntity = await _classRepository.GetClassById(id, tenantId) ?? throw new InvalidClassException(); - - await _classRepository.UpdateClass(id, tenantId, name); + var _classEntity = await _classRepository.GetClassById(id) ?? throw new InvalidClassException(); + await _classRepository.UpdateClass(id, name); } - public async Task DeleteClass(string id, string tenantId) + public async Task DeleteClass(string id) { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - - if (tenant.Type != TenantType.ORGANIZATION) - { - throw new InvalidTenantException(); - } - - var _classEntity = await _classRepository.GetClassById(id, tenantId) ?? throw new InvalidClassException(); - - await _classRepository.DeleteClass(id, tenantId); + var _classEntity = await _classRepository.GetClassById(id) ?? throw new InvalidClassException(); + await _classRepository.DeleteClass(id); } } diff --git a/backend/Application/Services/GradeSheetService.cs b/backend/Application/Services/GradeSheetService.cs index 06f30b6f..a83e4186 100644 --- a/backend/Application/Services/GradeSheetService.cs +++ b/backend/Application/Services/GradeSheetService.cs @@ -3,7 +3,6 @@ using Domain.Entities; using Domain.Exceptions; using Domain.Interfaces; -using Application.DTOs.Tenant; namespace Application.Services; @@ -13,21 +12,21 @@ public class GradeSheetService(IGradeSheetRepository gradeSheetRepository, IUser private readonly IFcmTokenRepository _fcmTokenRepository = fcmTokenRepository; private readonly INotificationProvider _notificationProvider = notificationProvider; - public async Task> GetGradeSheets(TokenTenantDTO tenant, string classId, UserType type, string userId) + public async Task> GetGradeSheets(string classId, UserType type, string userId) { if(type == UserType.STAFF) { - return await GetGradeSheetsForStaff(tenant, classId, userId); + return await GetGradeSheetsForStaff(classId, userId); } else { - return await GetGradeSheetsForStudents(tenant, classId, userId); + return await GetGradeSheetsForStudents(classId, userId); } } - private async Task> GetGradeSheetsForStaff(TokenTenantDTO tenant, string classId, string userId) + private async Task> GetGradeSheetsForStaff(string classId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheets = await _gradeSheetRepository.GetGradeSheetsByClassId(resolvedClassId); @@ -46,9 +45,9 @@ private async Task> GetGradeSheetsForStaff(TokenTenantDTO te return list; } - private async Task> GetGradeSheetsForStudents(TokenTenantDTO tenant, string classId, string studentId) + private async Task> GetGradeSheetsForStudents(string classId, string studentId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, studentId); + var resolvedClassId = await ResolveClassIdForUser(classId, studentId); var sheets = await _gradeSheetRepository.GetVisibleGradeSheetsWithRecordForStudent(resolvedClassId, studentId); @@ -61,9 +60,9 @@ private async Task> GetGradeSheetsForStudents(TokenTenantDTO })]; } - public async Task GetStudentGradeForSheet(TokenTenantDTO tenant, string classId, string gradeSheetId, string studentId) + public async Task GetStudentGradeForSheet(string classId, string gradeSheetId, string studentId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, studentId); + var resolvedClassId = await ResolveClassIdForUser(classId, studentId); var sheet = await _gradeSheetRepository.GetGradeSheet(gradeSheetId) ?? throw new GradeSheetNotFoundException(); if (!sheet.IsVisible || sheet.ClassId != resolvedClassId) throw new GradeSheetNotFoundException(); @@ -78,13 +77,13 @@ public async Task GetStudentGradeForSheet(TokenTenantDTO tenant Grade = record.Grade, }; } - public async Task> GetGradeSheetRecords(TokenTenantDTO tenant, string classId, string id, string userId) + public async Task> GetGradeSheetRecords(string classId, string id, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(id) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + List list = []; @@ -101,33 +100,33 @@ public async Task> GetGradeSheetRecords(TokenTenantDTO tena return list; } - public async Task CreateGradeSheet(string name, TokenTenantDTO tenant, string classId, bool isVisible, string userId) + public async Task CreateGradeSheet(string name, string classId, bool isVisible, string userId) { var uuid = Guid.NewGuid().ToString(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); await _gradeSheetRepository.CreateGradeSheet(uuid, name, resolvedClassId, isVisible); return uuid; } - public async Task DeleteGradeSheet(TokenTenantDTO tenant, string classId, string id, string userId) + public async Task DeleteGradeSheet(string classId, string id, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(id) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + await _gradeSheetRepository.DeleteGradeSheet(id); } - public async Task SetGradeSheetVisibility(TokenTenantDTO tenant, string classId, string id, bool visible, string userId) + public async Task SetGradeSheetVisibility(string classId, string id, bool visible, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(id) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + bool wasHidden = !sheet.IsVisible; bool becomingVisible = visible && wasHidden; @@ -142,19 +141,19 @@ public async Task SetGradeSheetVisibility(TokenTenantDTO tenant, string classId, } } - public async Task AddStudentGrade(TokenTenantDTO tenant, string classId, string gradeSheetId, string studentId, string grade, string userId) + public async Task AddStudentGrade(string classId, string gradeSheetId, string studentId, string grade, string userId) { var uuid = Guid.NewGuid().ToString(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(gradeSheetId) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + var student = await _userRepository.GetUserById(studentId) ?? throw new UserNotFoundException(); - if (student.TenantId != tenant.Id) throw new InvalidTenantException(); - else if (student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); + + if (student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); await _gradeSheetRepository.AddStudentRecord(uuid, gradeSheetId, studentId, grade); @@ -168,13 +167,13 @@ public async Task AddStudentGrade(TokenTenantDTO tenant, string classId, return uuid; } - public async Task> AddStudentGrades(TokenTenantDTO tenant, string classId, string gradeSheetId, List records, string userId) + public async Task> AddStudentGrades(string classId, string gradeSheetId, List records, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(gradeSheetId) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + var studentIdsToNotify = new List(); var uuids = new List(); @@ -188,7 +187,7 @@ public async Task> AddStudentGrades(TokenTenantDTO tenant, string c { if (!studentDict.TryGetValue(record.StudentId, out var student)) throw new UserNotFoundException(); - if (student.TenantId != tenant.Id) throw new InvalidTenantException(); + else if (student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); var uuid = Guid.NewGuid().ToString(); @@ -208,12 +207,12 @@ public async Task> AddStudentGrades(TokenTenantDTO tenant, string c return uuids; } - public async Task DeleteStudentRecord(TokenTenantDTO tenant, string classId, string gradeSheetId, string recordId, string userId) + public async Task DeleteStudentRecord(string classId, string gradeSheetId, string recordId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(gradeSheetId) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + var record = await _gradeSheetRepository.GetStudentRecordByRecordId(recordId) ?? throw new GradeRecordNotFoundException(); if (record.GradeSheetId != gradeSheetId) throw new InvalidGradeSheetException(); @@ -221,12 +220,12 @@ public async Task DeleteStudentRecord(TokenTenantDTO tenant, string classId, str await _gradeSheetRepository.DeleteStudentRecord(recordId); } - public async Task UpdateStudentRecord(TokenTenantDTO tenant, string classId, string gradeSheetId, string recordId, string grade, string userId) + public async Task UpdateStudentRecord(string classId, string gradeSheetId, string recordId, string grade, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var sheet = await _gradeSheetRepository.GetGradeSheet(gradeSheetId) ?? throw new GradeSheetNotFoundException(); - if (sheet.ClassId != resolvedClassId) throw new InvalidTenantException(); + var record = await _gradeSheetRepository.GetStudentRecordByRecordId(recordId) ?? throw new GradeRecordNotFoundException(); if (record.GradeSheetId != gradeSheetId) throw new InvalidGradeSheetException(); diff --git a/backend/Application/Services/MeetingService.cs b/backend/Application/Services/MeetingService.cs index 92b3ca94..ffb8659b 100644 --- a/backend/Application/Services/MeetingService.cs +++ b/backend/Application/Services/MeetingService.cs @@ -1,5 +1,4 @@ using Application.DTOs.Meetings; -using Application.DTOs.Tenant; using Domain.Enums; using Domain.Exceptions; using Domain.Interfaces; @@ -42,9 +41,9 @@ private string GenerateToken(string roomId, string userId, string userName) return token.ToJwt(); } - public async Task> GetMeetings(TokenTenantDTO tenant, string classId, string userId) + public async Task> GetMeetings(string classId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meetings = await _meetingRepository.GetMeetingsForClass(resolvedClassId); @@ -80,9 +79,9 @@ public async Task> GetMeetings(TokenTenantDTO tenant, string cl } public async Task<(MeetingDTO meeting, string token)> StartMeeting( - TokenTenantDTO tenant, string name, string classId, string userId, string userName) + string name, string classId, string userId, string userName) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.CreateMeeting(name, resolvedClassId, DateTime.UtcNow, MeetingStatus.ACTIVE); @@ -98,9 +97,9 @@ public async Task> GetMeetings(TokenTenantDTO tenant, string cl } public async Task JoinMeeting( - TokenTenantDTO tenant, int meetingId, string classId, string userId, string userName) + int meetingId, string classId, string userId, string userName) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.GetMeetingById(meetingId) ?? throw new Exception("Meeting not found"); @@ -114,9 +113,9 @@ public async Task JoinMeeting( return GenerateToken(meeting.RoomId, userId, userName); } - public async Task EndMeeting(TokenTenantDTO tenant, int meetingId, string classId, string userId) + public async Task EndMeeting(int meetingId, string classId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.GetMeetingById(meetingId) ?? throw new Exception("Meeting not found"); @@ -138,9 +137,9 @@ public async Task EndMeeting(TokenTenantDTO tenant, int meetingId, string classI } } - public async Task LeaveMeeting(TokenTenantDTO tenant, int meetingId, string classId, string userId) + public async Task LeaveMeeting(int meetingId, string classId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.GetMeetingById(meetingId) ?? throw new Exception("Meeting not found"); @@ -176,9 +175,9 @@ public async Task LeaveMeeting(TokenTenantDTO tenant, int meetingId, string clas } public async Task> GetChatMessages( - TokenTenantDTO tenant, int meetingId, string classId, string userId) + int meetingId, string classId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.GetMeetingById(meetingId) ?? throw new Exception("Meeting not found"); @@ -200,9 +199,9 @@ public async Task> GetChatMessages( } public async Task SendChatMessage( - TokenTenantDTO tenant, int meetingId, string classId, string userId, string userName, string message) + int meetingId, string classId, string userId, string userName, string message) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var meeting = await _meetingRepository.GetMeetingById(meetingId) ?? throw new Exception("Meeting not found"); diff --git a/backend/Application/Services/PlanEnforcementService.cs b/backend/Application/Services/PlanEnforcementService.cs deleted file mode 100644 index fe66add1..00000000 --- a/backend/Application/Services/PlanEnforcementService.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Domain.Entities; -using Domain.Exceptions; -using Domain.Interfaces; -using Microsoft.Extensions.Options; - -namespace Application.Services; - -public class PlanEnforcementService(ITenantRepository tenantRepository, IUserRepository userRepository, IOptions billingConfig) : IPlanEnforcementService -{ - private readonly ITenantRepository _tenantRepository = tenantRepository; - private readonly IUserRepository _userRepository = userRepository; - private readonly BillingConfig _config = billingConfig.Value; - - public async Task CheckStaffLimitAsync(string tenantId) - { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - if (tenant.Plan == null) return; - - var planName = tenant.Plan.ToString(); - if (_config.BillingPlans.TryGetValue(planName, out var planConfig)) - { - if (planConfig.MaxStaff == -1) return; // Unlimited - - var currentStaffCount = (await _userRepository.GetAllStaffByTenantId(tenantId)).Count; - if (currentStaffCount >= planConfig.MaxStaff) - { - throw new PlanLimitReachedException("PLAN_LIMIT_REACHED: Maximum staff limit reached for your current plan."); - } - } - } - - public async Task CheckStudentLimitAsync(string tenantId, int countToAdd = 1) - { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - if (tenant.Plan == null) return; - - var planName = tenant.Plan.ToString(); - if (_config.BillingPlans.TryGetValue(planName, out var planConfig)) - { - if (planConfig.MaxStudents == -1) return; // Unlimited - - var currentStudentCount = (await _userRepository.GetAllStudentsByTenantId(tenantId)).Count; - if (currentStudentCount + countToAdd > planConfig.MaxStudents) - { - throw new PlanLimitReachedException("PLAN_LIMIT_REACHED: Maximum student limit reached for your current plan."); - } - } - } - - public async Task CheckAIQuizFeatureAsync(string tenantId) - { - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - if (tenant.Plan == null) return; - - var planName = tenant.Plan.ToString(); - if (_config.BillingPlans.TryGetValue(planName, out var planConfig)) - { - if (!planConfig.AllowAIQuiz) - { - throw new PlanLimitReachedException("AI_QUIZ_NOT_ALLOWED: AI quiz generation is not supported on your current plan."); - } - } - } -} diff --git a/backend/Application/Services/QuizService.cs b/backend/Application/Services/QuizService.cs index 8cd2c451..e9bea91c 100644 --- a/backend/Application/Services/QuizService.cs +++ b/backend/Application/Services/QuizService.cs @@ -3,7 +3,6 @@ using Domain.Enums; using Domain.Exceptions; using Domain.Interfaces; -using Application.DTOs.Tenant; using System; using System.Collections.Generic; using System.Linq; @@ -13,15 +12,15 @@ namespace Application.Services; -public class QuizService(IQuizRepository quizRepository, IClassRepository classRepository, IUserRepository userRepository, IAIProvider aiProvider, IPlanEnforcementService planEnforcementService) : BaseClassService(classRepository, userRepository) +public class QuizService(IQuizRepository quizRepository, IClassRepository classRepository, IUserRepository userRepository, IAIProvider aiProvider) : BaseClassService(classRepository, userRepository) { private readonly IQuizRepository _quizRepository = quizRepository; private readonly IAIProvider _aiProvider = aiProvider; - private readonly IPlanEnforcementService _planEnforcementService = planEnforcementService; + - public async Task> GetAllQuizzes(TokenTenantDTO tenant, string classId, UserType userType, string userId) + public async Task> GetAllQuizzes(string classId, UserType userType, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); ICollection quizzes = await _quizRepository.GetAllQuizzes(resolvedClassId); @@ -51,9 +50,9 @@ public async Task> GetAllQuizzes(TokenTenantDTO ten } - public async Task GetQuizById(TokenTenantDTO tenant, string classId, string quizId, string userId) + public async Task GetQuizById(string classId, string quizId, string userId) { - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var quiz = await _quizRepository.GetQuizById(quizId , resolvedClassId) ?? throw new QuizNotFoundException(); @@ -87,13 +86,13 @@ public async Task GetQuizById(TokenTenantDTO tenant, string classId, st return quizDto; } - public async Task CreateQuiz(TokenTenantDTO tenant, string classId, string educatorId, QuizRequestDto request, string userId) + public async Task CreateQuiz(string classId, string educatorId, QuizRequestDto request, string userId) { if (request.TimeLimitInMinutes <= 0) throw new OutOfRangeException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); var uuid = Guid.NewGuid().ToString(); @@ -152,13 +151,12 @@ public async Task CreateQuiz(TokenTenantDTO tenant, string classId, stri return uuid; } - public async Task UpdateQuiz(TokenTenantDTO tenant, string classId, string quizId, UpdateQuizRequestDto request, string userId) + public async Task UpdateQuiz(string classId, string quizId, UpdateQuizRequestDto request, string userId) { var existingQuiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != existingQuiz.ClassId) - throw new InvalidTenantException(); + if (existingQuiz.IsPublished || existingQuiz.Questions.Count == 0) { @@ -221,15 +219,12 @@ public async Task UpdateQuiz(TokenTenantDTO tenant, string classId, string quizI await _quizRepository.UpdateQuiz(existingQuiz.Id , newTitle, newIsPublished, newStartsAt, newEndsAt, newTimeLimit, newQuestions, request.AutoGrade, request.ShowMarksAtEnd); } - public async Task StartQuiz(TokenTenantDTO tenant, string classId, string studentId, string quizId, string userId) + public async Task StartQuiz(string classId, string studentId, string quizId, string userId) { var quiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var submissions = await _quizRepository.GetStudentSubmissions(studentId, resolvedClassId); if (submissions.Any(s => s.QuizId == quizId)) @@ -269,15 +264,12 @@ public async Task StartQuiz(TokenTenantDTO tenant, strin }; } - public async Task SubmitQuiz(TokenTenantDTO tenant, string classId, string studentId, string quizId, List answers, string userId) + public async Task SubmitQuiz(string classId, string studentId, string quizId, List answers, string userId) { var quiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var quizSession = await _quizRepository.GetQuizSession(quizId, studentId) ?? throw new InvalidOperationException(); @@ -294,12 +286,6 @@ public async Task SubmitQuiz(TokenTenantDTO tenant, string classI bool needsManualGrading = false; bool isAIAllowed = true; - try { - await _planEnforcementService.CheckAIQuizFeatureAsync(tenant.Id); - } catch (PlanLimitReachedException) { - isAIAllowed = false; - } - foreach (var answer in answers) { var question = quiz.Questions.FirstOrDefault(q => q.Id == answer.QuestionId); @@ -365,15 +351,12 @@ public async Task SubmitQuiz(TokenTenantDTO tenant, string classI }; } - public async Task GetQuizScore(TokenTenantDTO tenant, string classId, string studentId, string quizId, string userId) + public async Task GetQuizScore(string classId, string studentId, string quizId, string userId) { var quiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var submissions = await _quizRepository.GetStudentSubmissions(studentId, resolvedClassId); var submission = submissions.FirstOrDefault(s => s.QuizId == quizId); @@ -391,29 +374,23 @@ public async Task GetQuizScore(TokenTenantDTO tenant, string clas }; } - public async Task DeleteQuiz(TokenTenantDTO tenant, string classId, string quizId, string userId) + public async Task DeleteQuiz(string classId, string quizId, string userId) { var existingQuiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != existingQuiz.ClassId) - { - throw new InvalidTenantException(); - } + await _quizRepository.DeleteQuiz(quizId); } - public async Task> GetSubmissionsForQuiz(TokenTenantDTO tenant, string classId, string quizId, string userId) + public async Task> GetSubmissionsForQuiz(string classId, string quizId, string userId) { var quiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var submissions = await _quizRepository.GetQuizSubmissions(quizId); @@ -429,7 +406,7 @@ public async Task> GetSubmissionsForQuiz(TokenTe }).ToList(); } - public async Task GetSubmissionDetails(TokenTenantDTO tenant, string classId, string quizId, string submissionId, string userId) + public async Task GetSubmissionDetails(string classId, string quizId, string submissionId, string userId) { var submission = await _quizRepository.GetSubmissionById(submissionId) ?? throw new QuizNotFoundException(); @@ -445,11 +422,8 @@ public async Task GetSubmissionDetails(TokenTenantDTO t throw new QuizNotFoundException(); } - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var resolvedClassId = await ResolveClassIdForUser(classId, userId); + return new QuizSubmissionDetailDto { @@ -470,15 +444,12 @@ public async Task GetSubmissionDetails(TokenTenantDTO t }; } - public async Task GetQuizAverageScore(TokenTenantDTO tenant, string classId, string quizId, string userId) + public async Task GetQuizAverageScore(string classId, string quizId, string userId) { var quiz = await _quizRepository.GetQuizById(quizId) ?? throw new QuizNotFoundException(); - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); + var resolvedClassId = await ResolveClassIdForUser(classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var submissions = await _quizRepository.GetQuizSubmissions(quizId); @@ -490,10 +461,9 @@ public async Task GetQuizAverageScore(TokenTenantDTO tenant, string clas return submissions.Average(s => s.TotalScore); } - public async Task> GenerateQuestionsAsync(GenerateQuestionsDto request, string tenantId) + public async Task> GenerateQuestionsAsync(GenerateQuestionsDto request) { - await _planEnforcementService.CheckAIQuizFeatureAsync(tenantId); - var questions = await _aiProvider.GenerateQuizQuestionsAsync(request.Prompt); + var questions = await _aiProvider.GenerateQuizQuestionsAsync(request.Prompt); return questions.Select(q => new QuestionDto { Id = Guid.NewGuid().ToString(), @@ -510,7 +480,7 @@ public async Task> GenerateQuestionsAsync(GenerateQuestionsDto }).ToList(); } - public async Task GradeSubmission(TokenTenantDTO tenant, string classId, string quizId, string submissionId, GradeSubmissionDto request, string userId) + public async Task GradeSubmission(string classId, string quizId, string submissionId, GradeSubmissionDto request, string userId) { var submission = await _quizRepository.GetSubmissionById(submissionId) ?? throw new QuizNotFoundException(); var quiz = submission.Quiz ?? await _quizRepository.GetQuizById(submission.QuizId); @@ -520,11 +490,8 @@ public async Task GradeSubmission(TokenTenantDTO tenant, string classId, string throw new QuizNotFoundException(); } - var resolvedClassId = await ResolveClassIdForUser(tenant, classId, userId); - if (resolvedClassId != quiz.ClassId) - { - throw new InvalidTenantException(); - } + var resolvedClassId = await ResolveClassIdForUser(classId, userId); + foreach (var answer in submission.Answers) { diff --git a/backend/Application/Services/StaffService.cs b/backend/Application/Services/StaffService.cs index c53b94f5..32f68da2 100644 --- a/backend/Application/Services/StaffService.cs +++ b/backend/Application/Services/StaffService.cs @@ -7,24 +7,21 @@ namespace Application.Services; -public class StaffService(IUserRepository userRepository, IEmailProvider emailProvider, IRefreshTokenRepository refreshTokenRepository, IClassRepository classRepository, IPlanEnforcementService planEnforcementService) +public class StaffService(IUserRepository userRepository, IEmailProvider emailProvider, IRefreshTokenRepository refreshTokenRepository, IClassRepository classRepository) { private readonly IUserRepository _userRepository = userRepository; private readonly IEmailProvider _emailProvider = emailProvider; private readonly IRefreshTokenRepository _refreshTokenRepository = refreshTokenRepository; private readonly IClassRepository _classRepository = classRepository; - private readonly IPlanEnforcementService _planEnforcementService = planEnforcementService; - public async Task> GetStaff(string tenantId) + public async Task> GetStaff() { - var staffMembers = await _userRepository.GetAllStaffByTenantId(tenantId); + var staffMembers = await _userRepository.GetAllStaff(); List list = []; - foreach (var user in staffMembers) { - var permissions = user.Permissions.Select(p => p.Type.ToString()).ToList(); list.Add(new StaffDTO() @@ -40,13 +37,10 @@ public async Task> GetStaff(string tenantId) return list; } - public async Task GetStaff(string tenantId, string staffId) + public async Task GetStaff(string staffId) { var staff = await _userRepository.GetUserById(staffId) ?? throw new UserNotFoundException(); - if (staff.TenantId != tenantId) - throw new InvalidTenantException(); - if (staff.Type != UserType.STAFF) throw new InvalidUserTypeException(); @@ -60,16 +54,13 @@ public async Task GetStaff(string tenantId, string staffId) }; } - public async Task AddStaff(string tenantName, string tenantId, TenantType tenantType, string name, string email, bool autogeneratePassword, string password, bool sendInstructions, ICollection permissions) + public async Task AddStaff(string name, string email, bool autogeneratePassword, string password, bool sendInstructions, ICollection permissions) { var existingStaff = await _userRepository.GetUserByEmail(email); - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); - - await _planEnforcementService.CheckStaffLimitAsync(tenantId); if(existingStaff != null) - throw new UserExistsException(); // my fault + throw new UserExistsException(); string userPassword; if (autogeneratePassword) @@ -97,9 +88,6 @@ public async Task AddStaff(string tenantName, string tenantId, TenantTyp if (!Enum.TryParse(p, true, out var parsed)) throw new InvalidPermissionException(); - if (tenantType == TenantType.SINGLE_CLASS && parsed == PermissionType.MANAGE_CLASSES) - throw new InvalidPermissionException(); - Permissions.Add(new Permission { Type = parsed, @@ -108,30 +96,22 @@ public async Task AddStaff(string tenantName, string tenantId, TenantTyp } } - await _userRepository.CreateUser(uuid, name, email, hashedPassword, UserType.STAFF, tenantId.ToString(), DateTime.UtcNow,Permissions); + await _userRepository.CreateUser(uuid, name, email, hashedPassword, UserType.STAFF, DateTime.UtcNow,Permissions); await _userRepository.SetEmailVerified(uuid, true); - if (tenantType == TenantType.SINGLE_CLASS && classes.Count > 0) - { - await _userRepository.AddUserToClass(uuid, classes[0].Id); - } - if (autogeneratePassword || sendInstructions) { - _emailProvider.SendEmail(email, "Access", $"You've been added to {tenantName} as a staff. Here's your password: {userPassword}"); + _emailProvider.SendEmail(email, "Access", $"You've been added as a staff. Here's your password: {userPassword}"); } return uuid; } - public async Task AssignClasses(string tenantId, string staffId, IEnumerable classIds) + public async Task AssignClasses(string staffId, IEnumerable classIds) { var staff = await _userRepository.GetUserById(staffId) ?? throw new UserNotFoundException(); - if (staff.TenantId != tenantId) - throw new InvalidTenantException(); - if (staff.Type != UserType.STAFF) throw new InvalidUserTypeException(); @@ -140,10 +120,10 @@ public async Task AssignClasses(string tenantId, string staffId, IEnumerable c.Id).ToList(); + var allClasses = await _classRepository.GetAllClasses(); + var allClassIds = allClasses.Select(c => c.Id).ToList(); - if (classIds.Any(id => !tenantClassIds.Contains(id))) + if (classIds.Any(id => !allClassIds.Contains(id))) { throw new InvalidClassException(); } @@ -151,14 +131,10 @@ public async Task AssignClasses(string tenantId, string staffId, IEnumerable? permissions) + public async Task UpdateStaffInfo(string staffId, string? name, string? email, ICollection? permissions) { var existingStaff = await _userRepository.GetUserById(staffId) ?? throw new UserNotFoundException(); - if (existingStaff.TenantId != tenantId) - { - throw new InvalidTenantException(); - } if (existingStaff.Type != UserType.STAFF) { throw new InvalidUserTypeException(); @@ -190,9 +166,6 @@ public async Task UpdateStaffInfo(string staffId, string tenantId, string? name, if (!Enum.TryParse(p, true, out var parsed)) throw new InvalidPermissionException(); - if (existingStaff.Tenant!.Type == TenantType.SINGLE_CLASS && parsed == PermissionType.MANAGE_CLASSES) - throw new InvalidPermissionException(); - Permissions.Add(new Permission { Type = parsed, @@ -203,14 +176,10 @@ public async Task UpdateStaffInfo(string staffId, string tenantId, string? name, await _userRepository.UpdateUser(staffId, updatedName, updatedEmail, existingStaff.Password, Permissions); } - public async Task ResetStaffPassword(string staffId, string tenantId, string tenantName, bool autogeneratePassword, string? password, bool sendInstructions) + public async Task ResetStaffPassword(string staffId, bool autogeneratePassword, string? password, bool sendInstructions) { var existingStaff = await _userRepository.GetUserById(staffId) ?? throw new UserNotFoundException(); - if (existingStaff.TenantId != tenantId) - { - throw new InvalidTenantException(); - } if (existingStaff.Type != UserType.STAFF) { throw new InvalidUserTypeException(); @@ -233,7 +202,7 @@ public async Task ResetStaffPassword(string staffId, string tenantId, string ten if (autogeneratePassword || sendInstructions) { - _emailProvider.SendEmail(existingStaff.Email, "Password Changed", $"Your password for {tenantName} has been changed. Your new password is: {newPassword}"); + _emailProvider.SendEmail(existingStaff.Email, "Password Changed", $"Your password has been changed. Your new password is: {newPassword}"); } await _userRepository.SetUserPassword(staffId, hashedPassword); @@ -241,49 +210,28 @@ public async Task ResetStaffPassword(string staffId, string tenantId, string ten - public async Task DeleteStaff(string staffId, string tenantId) + public async Task DeleteStaff(string staffId) { var existingStaff = await _userRepository.GetUserById(staffId); - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); if (existingStaff == null) { throw new UserNotFoundException(); } - if (existingStaff.TenantId != tenantId) - { - throw new InvalidTenantException(); - } if(existingStaff.Type != UserType.STAFF) { throw new InvalidUserTypeException(); } await _refreshTokenRepository.DeleteTokensByUserId(staffId); - - if (existingStaff.Tenant!.Type == TenantType.SINGLE_CLASS) - { - if (classes.Count > 0) - { - await _userRepository.RemoveUserFromClass(staffId, classes[0].Id); - } - } - else if (existingStaff.Tenant!.Type == TenantType.ORGANIZATION) - { - await _userRepository.SetUserClasses(staffId, []); - } - + await _userRepository.SetUserClasses(staffId, []); await _userRepository.DeleteUser(staffId); - } - public async Task> GetStaffClasses(string tenantId, string staffId) + public async Task> GetStaffClasses(string staffId) { var staff = await _userRepository.GetUserById(staffId) ?? throw new UserNotFoundException(); - if (staff.TenantId != tenantId) - throw new InvalidTenantException(); - if (staff.Type != UserType.STAFF) throw new InvalidUserTypeException(); diff --git a/backend/Application/Services/StudentService.cs b/backend/Application/Services/StudentService.cs index d3cb37e8..cbea09ef 100644 --- a/backend/Application/Services/StudentService.cs +++ b/backend/Application/Services/StudentService.cs @@ -8,17 +8,16 @@ namespace Application.Services; -public class StudentService(IUserRepository userRepository, IRefreshTokenRepository refreshTokenRepository, IClassRepository classRepository, IEmailProvider emailProvider, IPlanEnforcementService planEnforcementService) +public class StudentService(IUserRepository userRepository, IRefreshTokenRepository refreshTokenRepository, IClassRepository classRepository, IEmailProvider emailProvider) { private readonly IUserRepository _userRepository = userRepository; private readonly IRefreshTokenRepository _refreshTokenRepository = refreshTokenRepository; private readonly IClassRepository _classRepository = classRepository; private readonly IEmailProvider _emailProvider = emailProvider; - private readonly IPlanEnforcementService _planEnforcementService = planEnforcementService; - public async Task> GetStudents(string tenantId) + public async Task> GetStudents() { - var students = await _userRepository.GetAllStudentsByTenantId(tenantId); + var students = await _userRepository.GetAllStudents(); List list = []; @@ -34,13 +33,10 @@ public async Task> GetStudents(string tenantId) return list; } - public async Task GetStudent(string tenantId, string studentId) + public async Task GetStudent(string studentId) { var student = await _userRepository.GetUserById(studentId) ?? throw new UserNotFoundException(); - if(student.TenantId != tenantId) - throw new InvalidTenantException(); - if(student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); @@ -52,12 +48,9 @@ public async Task GetStudent(string tenantId, string studentId) CreatedAt = student.CreatedAt }; } - public async Task AddStudent(string tenantName, string tenantId, TenantType tenantType, string name, string email, bool autogeneratePassword, string password, bool sendInstructions) + public async Task AddStudent(string name, string email, bool autogeneratePassword, string password, bool sendInstructions) { var existingStudent = await _userRepository.GetUserByEmail(email); - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); - - await _planEnforcementService.CheckStudentLimitAsync(tenantId); if (existingStudent != null) { @@ -83,27 +76,19 @@ public async Task AddStudent(string tenantName, string tenantId, TenantT var uuid = Guid.NewGuid().ToString(); var hashedPassword = BCrypt.Net.BCrypt.HashPassword(userPassword, 10); - await _userRepository.CreateUser(uuid, name, email, hashedPassword, UserType.STUDENT, tenantId.ToString(), DateTime.UtcNow, []); + await _userRepository.CreateUser(uuid, name, email, hashedPassword, UserType.STUDENT, DateTime.UtcNow, []); await _userRepository.SetEmailVerified(uuid, true); - if(tenantType == TenantType.SINGLE_CLASS && classes.Count > 0) - { - await _userRepository.AddUserToClass(uuid, classes[0].Id); - } - if (autogeneratePassword || sendInstructions) { - _emailProvider.SendEmail(email, "Access", $"You've been added to {tenantName} as a student. Here's your password: {userPassword}"); + _emailProvider.SendEmail(email, "Access", $"You've been added as a student. Here's your password: {userPassword}"); } return uuid; } - public async Task> AddStudents(string tenantName, string tenantId, TenantType tenantType, List students) + public async Task> AddStudents(List students) { - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); - await _planEnforcementService.CheckStudentLimitAsync(tenantId, students.Count); - var studentEmails = students.Select(s => s.Email).ToList(); var existingStudents = await _userRepository.GetUsersByEmails(studentEmails); @@ -138,17 +123,12 @@ public async Task> AddStudents(string tenantName, string tenantId, var uuid = Guid.NewGuid().ToString(); var hashedPassword = BCrypt.Net.BCrypt.HashPassword(userPassword, 10); - await _userRepository.CreateUser(uuid, student.Name, student.Email, hashedPassword, UserType.STUDENT, tenantId.ToString(), DateTime.UtcNow, []); + await _userRepository.CreateUser(uuid, student.Name, student.Email, hashedPassword, UserType.STUDENT, DateTime.UtcNow, []); await _userRepository.SetEmailVerified(uuid, true); - if(tenantType == TenantType.SINGLE_CLASS && classes.Count > 0) - { - await _userRepository.AddUserToClass(uuid, classes[0].Id); - } - if (student.AutogeneratePassword || student.SendInstructions) { - _emailProvider.SendEmail(student.Email, "Access", $"You've been added to {tenantName} as a student. Here's your password: {userPassword}"); + _emailProvider.SendEmail(student.Email, "Access", $"You've been added as a student. Here's your password: {userPassword}"); } uuids.Add(uuid); @@ -157,20 +137,17 @@ public async Task> AddStudents(string tenantName, string tenantId, return uuids; } - public async Task AssignClasses(string tenantId, string studentId, IEnumerable classIds) + public async Task AssignClasses(string studentId, IEnumerable classIds) { var student = await _userRepository.GetUserById(studentId) ?? throw new UserNotFoundException(); - if (student.TenantId != tenantId) - throw new InvalidTenantException(); - if (student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); - var tenantClasses = await _classRepository.GetAllClassesByTenantId(tenantId); - var tenantClassIds = tenantClasses.Select(c => c.Id).ToList(); + var allClasses = await _classRepository.GetAllClasses(); + var allClassIds = allClasses.Select(c => c.Id).ToList(); - if (classIds.Any(id => !tenantClassIds.Contains(id))) + if (classIds.Any(id => !allClassIds.Contains(id))) { throw new InvalidClassException(); } @@ -178,13 +155,10 @@ public async Task AssignClasses(string tenantId, string studentId, IEnumerable 0) - { - await _userRepository.RemoveUserFromClass(studentId, classes[0].Id); - } - } - else if (student.Tenant!.Type == TenantType.ORGANIZATION) - { - await _userRepository.SetUserClasses(studentId, []); - } - + await _userRepository.SetUserClasses(studentId, []); await _userRepository.DeleteUser(studentId); } - public async Task> GetStudentClasses(string tenantId, string studentId) + public async Task> GetStudentClasses(string studentId) { var student = await _userRepository.GetUserById(studentId) ?? throw new UserNotFoundException(); - if (student.TenantId != tenantId) - throw new InvalidTenantException(); - if (student.Type != UserType.STUDENT) throw new InvalidUserTypeException(); @@ -298,6 +249,4 @@ public static string GenerateRandomPassword(int length = 12) return new string(password); } - } - diff --git a/backend/Application/Services/TenantService.cs b/backend/Application/Services/TenantService.cs deleted file mode 100644 index f5dd1c73..00000000 --- a/backend/Application/Services/TenantService.cs +++ /dev/null @@ -1,159 +0,0 @@ -using Application.DTOs.Tenant; -using Domain.Entities; -using Domain.Enums; -using Domain.Exceptions; -using Domain.Interfaces; - -namespace Application.Services; - -public class TenantService(ITenantRepository tenantRepository, IUserRepository userRepository, IClassRepository classRepository, IEmailProvider emailProvider) -{ - private readonly ITenantRepository _tenantRepository = tenantRepository; - private readonly IUserRepository _userRepository = userRepository; - private readonly IClassRepository _classRepository = classRepository; - private readonly IEmailProvider _emailProvider = emailProvider; - - private void ValidateMockPayment(string cardNumber, string expiry, string cvv, string name) - { - if (cardNumber.Replace(" ", "") != "1234123412341234" || - expiry != "06/2026" || - cvv != "123" || - name != "Mohamed Eldahtoury") - { - throw new PaymentFailedException(); - } - } - - private async Task SendReceipt(string email, string plan, double amount, string last4) - { - var body = $"**Transaction ID:** {Guid.NewGuid()}\n" + - $"**Plan:** {plan}\n" + - $"**Amount:** ${amount:F2}\n" + - $"**Date:** {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC\n" + - $"**Card:** **** **** **** {last4}"; - - _emailProvider.SendEmail(email, "Payment Receipt", body); - } - - public async Task CreateSingleClassTenant(string userId, string name, string planStr, string cardNumber, string expiry, string cvv, string holderName) - { - ValidateMockPayment(cardNumber, expiry, cvv, holderName); - - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - var plan = planStr.ToLower() switch - { - "basic" => SubscriptionPlan.BASIC, - "pro" => SubscriptionPlan.PRO, - "premium" => SubscriptionPlan.PREMIUM, - _ => throw new InvalidTenantException() - }; - - var amount = plan switch - { - SubscriptionPlan.BASIC => 9.99, - SubscriptionPlan.PRO => 19.99, - SubscriptionPlan.PREMIUM => 49.99, - _ => 0 - }; - - var tenantId = Guid.NewGuid().ToString(); - await _tenantRepository.CreateTenant( - tenantId, - name, - TenantType.SINGLE_CLASS, - plan, - "ACTIVE", - DateTime.UtcNow.AddMonths(1), - "Visa", - cardNumber[^4..], - expiry, - holderName - ); - - var classId = Guid.NewGuid().ToString(); - await _classRepository.CreateClass(classId, name, tenantId); - - await _userRepository.SetUserTenant(userId, tenantId); - await _userRepository.AddUserToClass(userId, classId); - - await SendReceipt(user.Email, plan.ToString(), amount, cardNumber[^4..]); - } - - public async Task CreateOrganizationTenant(string userId, string name, string planStr, string cardNumber, string expiry, string cvv, string holderName) - { - ValidateMockPayment(cardNumber, expiry, cvv, holderName); - - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - var plan = planStr.ToLower() switch - { - "starter" => SubscriptionPlan.STARTER, - "growth" => SubscriptionPlan.GROWTH, - "enterprise" => SubscriptionPlan.ENTERPRISE, - "custom" => SubscriptionPlan.CUSTOM, - _ => throw new InvalidOperationException() - }; - - var amount = plan switch - { - SubscriptionPlan.STARTER => 99.99, - SubscriptionPlan.GROWTH => 199.99, - SubscriptionPlan.ENTERPRISE => 499.99, - SubscriptionPlan.CUSTOM => 0.0, - _ => throw new InvalidOperationException() - }; - - var tenantId = Guid.NewGuid().ToString(); - await _tenantRepository.CreateTenant( - tenantId, - name, - TenantType.ORGANIZATION, - plan, - "ACTIVE", - DateTime.UtcNow.AddMonths(1), - "Visa", - cardNumber[^4..], - expiry, - holderName - ); - - await _userRepository.SetUserTenant(userId, tenantId); - - await SendReceipt(user.Email, plan.ToString(), amount, cardNumber[^4..]); - } - - public async Task DeleteTenant(string userId, string tenantId, string password) - { - var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - var tenant = await _tenantRepository.GetTenantById(tenantId) ?? throw new TenantNotFoundException(); - - if (!BCrypt.Net.BCrypt.Verify(password, user.Password)) - throw new InvalidCredentialsException(); - - var classes = await _classRepository.GetAllClassesByTenantId(tenantId); - if (tenant.Type == TenantType.ORGANIZATION) - { - foreach (var c in classes) - { - await _userRepository.RemoveUserFromClass(userId, c.Id); - } - } - - await _classRepository.DeleteClassesByTenantId(tenantId); - await _userRepository.DeleteUsersByTenantId(tenantId); - await _tenantRepository.DeleteTenant(tenant.Id); - - } - public async Task UpdateTenant(TokenTenantDTO tenant, string name) - { - await _tenantRepository.SetTenantName(tenant.Id, name); - if (tenant.Type == TenantType.SINGLE_CLASS) - { - await _classRepository.UpdateClassNameByTenantId(tenant.Id, name); - } - } - - public async Task GetTenantById(string tenantId) - { - return await _tenantRepository.GetTenantById(tenantId); - } -} diff --git a/backend/Application/Services/UserService.cs b/backend/Application/Services/UserService.cs index 000594e0..da97aee6 100644 --- a/backend/Application/Services/UserService.cs +++ b/backend/Application/Services/UserService.cs @@ -8,12 +8,10 @@ public class UserService (IUserRepository userRepository) { private readonly IUserRepository _userRepository = userRepository; - public async Task GetUserProfile(string tenantId, string userId) + public async Task GetUserProfile(string userId) { var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - if (user.TenantId != tenantId) throw new InvalidTenantException(); - return new UserProfileDTO { Name = user.Name, @@ -27,12 +25,10 @@ public async Task UpdateName(string userId, string name) await _userRepository.SetUserName(userId, name); } - public async Task> GetUserClasses(string userId, string tenantId) + public async Task> GetUserClasses(string userId) { var user = await _userRepository.GetUserById(userId) ?? throw new UserNotFoundException(); - if (user.TenantId != tenantId) throw new InvalidTenantException(); - return user.Classes.Select(c => new UserClassDTO { Id = c.Id, diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs index ddda9bd2..c5714eee 100644 --- a/backend/Data/AppDbContext.cs +++ b/backend/Data/AppDbContext.cs @@ -6,7 +6,7 @@ namespace Data; public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet Users => Set(); - public DbSet Tenants => Set(); + public DbSet RefreshTokens => Set(); public DbSet Classes => Set(); public DbSet Permissions => Set(); @@ -40,10 +40,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) action.Property(u => u.Email).HasColumnName("email").IsRequired(); action.Property(u => u.Password).HasColumnName("password").IsRequired(); action.Property(u => u.Type).HasColumnName("type").HasConversion(); - action.Property(u => u.TenantId).HasColumnName("tenant"); action.Property(u => u.IsEmailVerified).HasColumnName("is_email_verified").HasDefaultValue(false); action.Property(u => u.CreatedAt).HasColumnName("created_at").HasDefaultValue(new DateTime(2026, 1, 1)); - action.HasOne(u => u.Tenant).WithMany().HasForeignKey(u => u.TenantId).OnDelete(DeleteBehavior.Cascade); action.HasMany(u => u.Classes).WithMany(c => c.Users).UsingEntity>( "user_classes", j => j .HasOne() @@ -62,14 +60,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity(action => - { - action.ToTable("tenants"); - action.HasKey(u => u.Id); - action.Property(u => u.Id).HasColumnName("id"); - action.Property(u => u.Name).HasColumnName("name").IsRequired(); - action.Property(u => u.Type).HasColumnName("type").HasConversion(); - }); + modelBuilder.Entity(action => { @@ -87,8 +78,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) action.HasKey(r => r.Id); action.Property(u => u.Id).HasColumnName("id"); action.Property(u => u.Name).HasColumnName("name"); - action.Property(u => u.TenantId).HasColumnName("tenant"); - action.HasOne(u => u.Tenant).WithMany().HasForeignKey(r => r.TenantId).OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(action => diff --git a/backend/Data/Repositories/ClassRepository.cs b/backend/Data/Repositories/ClassRepository.cs index e616c55a..dfa4722f 100644 --- a/backend/Data/Repositories/ClassRepository.cs +++ b/backend/Data/Repositories/ClassRepository.cs @@ -1,4 +1,4 @@ -using Domain.Entities; +using Domain.Entities; using Domain.Interfaces; using Microsoft.EntityFrameworkCore; using System.Threading.Tasks; @@ -9,47 +9,46 @@ public class ClassRepository(AppDbContext appDbContext) : IClassRepository { private readonly AppDbContext _context = appDbContext; - public async Task CreateClass(string id, string name, string tenantId) + public async Task CreateClass(string id, string name) { - _context.Classes.Add(new Class(id, name, tenantId)); + _context.Classes.Add(new Class(id, name)); await _context.SaveChangesAsync(); } - public async Task DeleteClassesByTenantId(string tenantId) + public async Task DeleteAllClasses() { await _context.Classes - .Where(u => u.TenantId == tenantId) .ExecuteDeleteAsync(); } - public async Task> GetAllClassesByTenantId(string tenantId) + public async Task> GetAllClasses() { - return await _context.Classes.Where(c => c.TenantId == tenantId).ToListAsync(); + return await _context.Classes.ToListAsync(); } - public async Task UpdateClassNameByTenantId(string tenantId, string name) + public async Task UpdateClassName(string id, string name) { await _context.Classes - .Where(u => u.TenantId == tenantId) + .Where(u => u.Id == id) .ExecuteUpdateAsync(u => u.SetProperty(u => u.Name, name)); } - public async Task GetClassById(string id, string tenantId) + public async Task GetClassById(string id) { - return await _context.Classes.FirstOrDefaultAsync(c => c.Id == id && c.TenantId == tenantId); + return await _context.Classes.FirstOrDefaultAsync(c => c.Id == id); } - public async Task DeleteClass(string id, string tenantId) + public async Task DeleteClass(string id) { await _context.Classes - .Where(c => c.Id == id && c.TenantId == tenantId) + .Where(c => c.Id == id) .ExecuteDeleteAsync(); } - public async Task UpdateClass(string id, string tenantId, string name) + public async Task UpdateClass(string id, string name) { await _context.Classes - .Where(c => c.Id == id && c.TenantId == tenantId) + .Where(c => c.Id == id) .ExecuteUpdateAsync(c => c.SetProperty(c => c.Name, name)); } } diff --git a/backend/Data/Repositories/TenantRepository.cs b/backend/Data/Repositories/TenantRepository.cs deleted file mode 100644 index 74512564..00000000 --- a/backend/Data/Repositories/TenantRepository.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Domain.Entities; -using Domain.Enums; -using Domain.Interfaces; -using Domain.Exceptions; -using Microsoft.EntityFrameworkCore; - -namespace Data.Repositories; - -public class TenantRepository(AppDbContext appDbContext) : ITenantRepository -{ - private readonly AppDbContext _context = appDbContext; - public async Task GetTenantById(string id) - { - return await _context.Tenants - .FirstOrDefaultAsync(t => t.Id == id); - } - - public async Task CreateTenant(string id, string name, TenantType type, SubscriptionPlan plan, string status, DateTime? nextBillingDate, string cardBrand, string last4, string cardExpiry, string cardHolderName) - { - _context.Tenants.Add(new Tenant - { - Id = id, - Name = name, - Type = type, - Plan = plan, - SubscriptionStatus = status, - NextBillingDate = nextBillingDate, - CardBrand = cardBrand, - Last4 = last4, - CardExpiry = cardExpiry, - CardHolderName = cardHolderName - }); - await _context.SaveChangesAsync(); - } - - public async Task DeleteTenant(string id) - { - var rows = await _context.Tenants - .Where(t => t.Id == id) - .ExecuteDeleteAsync(); - - if (rows == 0) throw new TenantNotFoundException(); - } - - public async Task SetTenantName(string tenantId, string name) - { - var rows = await _context.Tenants - .Where(u => u.Id == tenantId) - .ExecuteUpdateAsync(u => u.SetProperty(u => u.Name, name)); - } - - public async Task UpdateSubscription(string tenantId, SubscriptionPlan plan, string status, DateTime? nextBillingDate) - { - await _context.Tenants - .Where(t => t.Id == tenantId) - .ExecuteUpdateAsync(t => t - .SetProperty(t => t.Plan, plan) - .SetProperty(t => t.SubscriptionStatus, status) - .SetProperty(t => t.NextBillingDate, nextBillingDate)); - } - - public async Task UpdateCard(string tenantId, string cardBrand, string last4, string cardExpiry, string cardHolderName) - { - await _context.Tenants - .Where(t => t.Id == tenantId) - .ExecuteUpdateAsync(t => t - .SetProperty(t => t.CardBrand, cardBrand) - .SetProperty(t => t.Last4, last4) - .SetProperty(t => t.CardExpiry, cardExpiry) - .SetProperty(t => t.CardHolderName, cardHolderName)); - } -} diff --git a/backend/Data/Repositories/UserRepository.cs b/backend/Data/Repositories/UserRepository.cs index 57ba98ce..5ff0d94f 100644 --- a/backend/Data/Repositories/UserRepository.cs +++ b/backend/Data/Repositories/UserRepository.cs @@ -9,9 +9,9 @@ namespace Data.Repositories; public class UserRepository(AppDbContext appDbContext) : IUserRepository { private readonly AppDbContext _context = appDbContext; - public async Task CreateUser(string id, string name,string email, string password, UserType type, string? tenantId, DateTime createdAt, ICollection permissions) + public async Task CreateUser(string id, string name,string email, string password, UserType type, DateTime createdAt, ICollection permissions) { - _context.Users.Add(new User(id, name, email, password, type, tenantId, createdAt, permissions)); + _context.Users.Add(new User(id, name, email, password, type, createdAt, permissions)); await _context.SaveChangesAsync(); } public async Task UpdateUser(string id, string name,string email, string password, ICollection permissions) @@ -52,14 +52,12 @@ await _context.Users { return await _context.Users .Include(u => u.Permissions) - .Include(u => u.Tenant) .FirstOrDefaultAsync(u => u.Email == email); } public async Task GetUserById(string id) { return await _context.Users .Include(u => u.Permissions) - .Include(u => u.Tenant) .Include(u => u.Classes) .FirstOrDefaultAsync(u => u.Id == id); } @@ -67,23 +65,14 @@ public async Task> GetUsersByIds(IEnumerable ids) { return await _context.Users .Include(u => u.Permissions) - .Include(u => u.Tenant) .Where(u => ids.Contains(u.Id)).ToListAsync(); } public async Task> GetUsersByEmails(IEnumerable emails) { return await _context.Users .Include(u => u.Permissions) - .Include(u => u.Tenant) .Where(u => emails.Contains(u.Email)).ToListAsync(); } - public async Task SetUserTenant(string userId, string? tenantId) - { - await _context.Users - .Where(u => u.Id == userId) - .ExecuteUpdateAsync(u => u.SetProperty(u => u.TenantId, tenantId)); - - } public async Task SetUserName(string userId, string name) { await _context.Users @@ -91,10 +80,9 @@ await _context.Users .ExecuteUpdateAsync(u => u.SetProperty(u => u.Name, name)); } - public async Task DeleteUsersByTenantId(string tenantId) + public async Task DeleteAllUsers() { await _context.Users - .Where(u => u.TenantId == tenantId) .ExecuteDeleteAsync(); } @@ -119,18 +107,16 @@ await _context.Users .ExecuteDeleteAsync(); } - public async Task> GetAllStudentsByTenantId(string tenantId) + public async Task> GetAllStudents() { return await _context.Users - .Where(u => u.TenantId == tenantId) .Where(u => u.Type == UserType.STUDENT) .Include(p =>p.Permissions) .ToListAsync(); } - public async Task> GetAllStaffByTenantId(string tenantId) + public async Task> GetAllStaff() { return await _context.Users - .Where(u => u.TenantId == tenantId) .Where(u => u.Type == UserType.STAFF) .Include(p =>p.Permissions) .ToListAsync(); diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..4319e64d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +COPY ["Api/Api.csproj", "Api/"] +COPY ["Application/Application.csproj", "Application/"] +COPY ["Domain/Domain.csproj", "Domain/"] +COPY ["Infrastructure/Infrastructure.csproj", "Infrastructure/"] +COPY ["Data/Data.csproj", "Data/"] +RUN dotnet restore "Api/Api.csproj" + +COPY . . +WORKDIR "/src/Api" +RUN dotnet build "Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Api.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +WORKDIR /app +EXPOSE 8080 + +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Api.dll"] diff --git a/backend/Domain/Entities/BillingConfig.cs b/backend/Domain/Entities/BillingConfig.cs deleted file mode 100644 index 8c1a77ad..00000000 --- a/backend/Domain/Entities/BillingConfig.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Domain.Entities; - -public class BillingPlanConfig -{ - public int MaxStaff { get; set; } - public int MaxStudents { get; set; } - public bool AllowAIQuiz { get; set; } -} - -public class BillingConfig -{ - public Dictionary BillingPlans { get; set; } = new(); -} diff --git a/backend/Domain/Entities/Class.cs b/backend/Domain/Entities/Class.cs index 163faecd..b4fdeebe 100644 --- a/backend/Domain/Entities/Class.cs +++ b/backend/Domain/Entities/Class.cs @@ -1,21 +1,18 @@ -namespace Domain.Entities; +namespace Domain.Entities; public class Class { public string Id { get; set; } = null!; public string Name { get; set; } = null!; - public string TenantId { get; set; } = null!; - public Tenant Tenant { get; set; } = null!; public ICollection Users { get; set; } = []; public Class() { } - public Class(string id, string name, string tenantId) + public Class(string id, string name) { Id = id; Name = name; - TenantId = tenantId; } } diff --git a/backend/Domain/Entities/Tenant.cs b/backend/Domain/Entities/Tenant.cs deleted file mode 100644 index fb1f9011..00000000 --- a/backend/Domain/Entities/Tenant.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Domain.Enums; - -namespace Domain.Entities; -public class Tenant -{ - public string Id { get; set; } = null!; - public string Name { get; set; } = null!; - public TenantType? Type { get; set; } - - // Subscription Fields - public SubscriptionPlan? Plan { get; set; } - public string? SubscriptionStatus { get; set; } // ACTIVE, CANCELED, PAST_DUE - public DateTime? NextBillingDate { get; set; } - - // Mock Card Info - public string? CardBrand { get; set; } - public string? Last4 { get; set; } - public string? CardExpiry { get; set; } - public string? CardHolderName { get; set; } - - public Tenant() { } - - public Tenant(string id, string name, TenantType type) - { - Id = id; - Name = name; - Type = type; - } - - public Tenant(string id, string name, TenantType type, SubscriptionPlan plan, string status, DateTime? nextBillingDate) - { - Id = id; - Name = name; - Type = type; - Plan = plan; - SubscriptionStatus = status; - NextBillingDate = nextBillingDate; - } -} diff --git a/backend/Domain/Entities/User.cs b/backend/Domain/Entities/User.cs index d7251074..e7c6e933 100644 --- a/backend/Domain/Entities/User.cs +++ b/backend/Domain/Entities/User.cs @@ -8,8 +8,7 @@ public class User public string Name { get; set; } = null!; public string Email { get; set; } = null!; public string Password { get; set; } = null!; - public string? TenantId { get; set; } - public Tenant? Tenant { get; set; } + public UserType Type { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; @@ -21,14 +20,14 @@ public class User public User() { } - public User(string id, string name, string email, string password, UserType type, string? tenantId, DateTime createdAt, ICollection permissions) + public User(string id, string name, string email, string password, UserType type, DateTime createdAt, ICollection permissions) { Id = id; Name = name; Email = email; Password = password; Type = type; - TenantId = tenantId; + Permissions = permissions; CreatedAt = createdAt; } diff --git a/backend/Domain/Enums/BillingPlans.cs b/backend/Domain/Enums/BillingPlans.cs deleted file mode 100644 index 630b2795..00000000 --- a/backend/Domain/Enums/BillingPlans.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Domain.Enums; - -public enum BillingPlans -{ - SINGLE_CLASS_BASIC, - SINGLE_CLASS_PRO, - SINGLE_CLASS_PREMIUM, - CUSTOM, -} diff --git a/backend/Domain/Enums/SubscriptionPlan.cs b/backend/Domain/Enums/SubscriptionPlan.cs deleted file mode 100644 index 11ec233a..00000000 --- a/backend/Domain/Enums/SubscriptionPlan.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Domain.Enums; - -public enum SubscriptionPlan -{ - // Single Class Plans - BASIC, - PRO, - PREMIUM, - - // Organization Plans - STARTER, - GROWTH, - ENTERPRISE, - - // Other Plans - CUSTOM -} diff --git a/backend/Domain/Enums/TenantType.cs b/backend/Domain/Enums/TenantType.cs deleted file mode 100644 index adf7c355..00000000 --- a/backend/Domain/Enums/TenantType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Domain.Enums; - -public enum TenantType -{ - SINGLE_CLASS, - ORGANIZATION -} diff --git a/backend/Domain/Exceptions/InvalidTenantException.cs b/backend/Domain/Exceptions/InvalidTenantException.cs deleted file mode 100644 index ecba6198..00000000 --- a/backend/Domain/Exceptions/InvalidTenantException.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Domain.Exceptions; - -public class InvalidTenantException : Exception -{ -} diff --git a/backend/Domain/Exceptions/PlanLimitReachedException.cs b/backend/Domain/Exceptions/PlanLimitReachedException.cs deleted file mode 100644 index f0d6a386..00000000 --- a/backend/Domain/Exceptions/PlanLimitReachedException.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Domain.Exceptions; - -public class PlanLimitReachedException : Exception -{ - public PlanLimitReachedException() : base("Plan limit reached.") { } - public PlanLimitReachedException(string message) : base(message) { } -} diff --git a/backend/Domain/Exceptions/TenantNotFoundException.cs b/backend/Domain/Exceptions/TenantNotFoundException.cs deleted file mode 100644 index a6543531..00000000 --- a/backend/Domain/Exceptions/TenantNotFoundException.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Domain.Exceptions; - -public class TenantNotFoundException : Exception -{ -} diff --git a/backend/Domain/Interfaces/IClassRepository.cs b/backend/Domain/Interfaces/IClassRepository.cs index 09cd1217..6af8cd1a 100644 --- a/backend/Domain/Interfaces/IClassRepository.cs +++ b/backend/Domain/Interfaces/IClassRepository.cs @@ -1,17 +1,17 @@ -using Domain.Entities; +using Domain.Entities; namespace Domain.Interfaces; public interface IClassRepository { - Task CreateClass(string id, string name, string tenantId); - Task> GetAllClassesByTenantId(string tenantId); + Task CreateClass(string id, string name); + Task> GetAllClasses(); - Task DeleteClassesByTenantId(string tenantId); + Task DeleteAllClasses(); - Task UpdateClassNameByTenantId(string tenantId, string name); + Task UpdateClassName(string id, string name); - Task GetClassById(string id, string tenantId); - Task DeleteClass(string id, string tenantId); - Task UpdateClass(string id, string tenantId, string name); + Task GetClassById(string id); + Task DeleteClass(string id); + Task UpdateClass(string id, string name); } diff --git a/backend/Domain/Interfaces/IPlanEnforcementService.cs b/backend/Domain/Interfaces/IPlanEnforcementService.cs deleted file mode 100644 index f6671ba6..00000000 --- a/backend/Domain/Interfaces/IPlanEnforcementService.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Domain.Enums; - -namespace Domain.Interfaces; - -public interface IPlanEnforcementService -{ - Task CheckStaffLimitAsync(string tenantId); - Task CheckStudentLimitAsync(string tenantId, int countToAdd = 1); - Task CheckAIQuizFeatureAsync(string tenantId); -} diff --git a/backend/Domain/Interfaces/ITenantRepository.cs b/backend/Domain/Interfaces/ITenantRepository.cs deleted file mode 100644 index 224bd85e..00000000 --- a/backend/Domain/Interfaces/ITenantRepository.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Domain.Entities; -using Domain.Enums; - -namespace Domain.Interfaces; - -public interface ITenantRepository -{ - Task GetTenantById(string id); - Task CreateTenant(string id, string name, TenantType type, SubscriptionPlan plan, string status, DateTime? nextBillingDate, string cardBrand, string last4, string cardExpiry, string cardHolderName); - Task DeleteTenant(string id); - Task SetTenantName(string tenantId, string name); - Task UpdateSubscription(string tenantId, SubscriptionPlan plan, string status, DateTime? nextBillingDate); - Task UpdateCard(string tenantId, string cardBrand, string last4, string cardExpiry, string cardHolderName); -} diff --git a/backend/Domain/Interfaces/IUserJwtProvider.cs b/backend/Domain/Interfaces/IUserJwtProvider.cs index e3230ae9..d97e8cbf 100644 --- a/backend/Domain/Interfaces/IUserJwtProvider.cs +++ b/backend/Domain/Interfaces/IUserJwtProvider.cs @@ -1,9 +1,9 @@ -using Domain.Entities; +using Domain.Entities; using Domain.Enums; namespace Domain.Interfaces; public interface IUserJwtProvider { - string CreateToken(string id, string name, string email, Tenant? tenant, UserType type, IEnumerable permissions); + string CreateToken(string id, string name, string email, UserType type, IEnumerable permissions); } diff --git a/backend/Domain/Interfaces/IUserRepository.cs b/backend/Domain/Interfaces/IUserRepository.cs index 48a95df2..ade6c41f 100644 --- a/backend/Domain/Interfaces/IUserRepository.cs +++ b/backend/Domain/Interfaces/IUserRepository.cs @@ -5,19 +5,18 @@ namespace Domain.Interfaces; public interface IUserRepository { - Task CreateUser(string id, string name, string email, string password, UserType type, string? tenantId, DateTime createdAt, ICollection permissions); + Task CreateUser(string id, string name, string email, string password, UserType type, DateTime createdAt, ICollection permissions); Task UpdateUser(string id, string name, string email, string password,ICollection permissions); Task SetUserPassword(string userId, string hashedPassword); Task SetEmailVerified(string userId, bool verified); Task GetUserByEmail(string email); - Task> GetAllStudentsByTenantId(string tenantId); - Task> GetAllStaffByTenantId(string tenantId); + Task> GetAllStudents(); + Task> GetAllStaff(); Task> GetUsersByIds(IEnumerable ids); Task> GetUsersByEmails(IEnumerable emails); Task GetUserById(string id); - Task SetUserTenant(string userId, string tenantId); Task SetUserName(string userId, string name); - Task DeleteUsersByTenantId(string tenantId); + Task DeleteAllUsers(); Task AddUserToClass(string userId, string classId); Task RemoveUserFromClass(string userId, string classId); Task SetUserClasses(string userId, IEnumerable classIds); diff --git a/backend/Infrastructure/UserJwtProvider.cs b/backend/Infrastructure/UserJwtProvider.cs index 0dc96ee6..9d9a999a 100644 --- a/backend/Infrastructure/UserJwtProvider.cs +++ b/backend/Infrastructure/UserJwtProvider.cs @@ -16,22 +16,14 @@ public class UserJwtProvider(string secret) : IUserJwtProvider { private readonly string _secret = secret; - public string CreateToken(string id, string name, string email, Tenant? tenant, UserType type, IEnumerable permissions) + public string CreateToken(string id, string name, string email, UserType type, IEnumerable permissions) { - var tenantJson = tenant != null ? JsonSerializer.Serialize(new - { - id = tenant.Id, - name = tenant.Name, - type = tenant.Type.ToString(), - plan = tenant.Plan?.ToString() - }) : JsonSerializer.Serialize(new { }); var claims = new List { new(JwtRegisteredClaimNames.Sub, id), new(JwtRegisteredClaimNames.Email, email), new(JwtRegisteredClaimNames.Name, name), - new("tenant", tenantJson, JsonClaimValueTypes.Json), new("type", type.ToString()) }; diff --git a/backend/YASPO-Backend.sln b/backend/YASPO-Backend.sln index ed8e579b..1b9b30dc 100644 --- a/backend/YASPO-Backend.sln +++ b/backend/YASPO-Backend.sln @@ -1,55 +1,49 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{8A517778-9000-4835-9458-4C65E9875BE4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{459F7F15-603B-4966-9842-E7EDD503F74D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api\Api.csproj", "{CBFA08EA-AECE-205F-7B85-326FD0B81BAD}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application.Tests", "Application.Tests\Application.Tests.csproj", "{FDC5F802-5EF1-6730-1AB1-14568EEBEFB5}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Release|Any CPU.Build.0 = Release|Any CPU - {8A517778-9000-4835-9458-4C65E9875BE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8A517778-9000-4835-9458-4C65E9875BE4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8A517778-9000-4835-9458-4C65E9875BE4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8A517778-9000-4835-9458-4C65E9875BE4}.Release|Any CPU.Build.0 = Release|Any CPU - {459F7F15-603B-4966-9842-E7EDD503F74D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {459F7F15-603B-4966-9842-E7EDD503F74D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {459F7F15-603B-4966-9842-E7EDD503F74D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {459F7F15-603B-4966-9842-E7EDD503F74D}.Release|Any CPU.Build.0 = Release|Any CPU - {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.Build.0 = Release|Any CPU - {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Release|Any CPU.Build.0 = Release|Any CPU - {FDC5F802-5EF1-6730-1AB1-14568EEBEFB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FDC5F802-5EF1-6730-1AB1-14568EEBEFB5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FDC5F802-5EF1-6730-1AB1-14568EEBEFB5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FDC5F802-5EF1-6730-1AB1-14568EEBEFB5}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {B1614805-C329-4E81-98FF-6076210F0511} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "Application\Application.csproj", "{E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Data", "Data\Data.csproj", "{8A517778-9000-4835-9458-4C65E9875BE4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "Domain\Domain.csproj", "{459F7F15-603B-4966-9842-E7EDD503F74D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "Api\Api.csproj", "{CBFA08EA-AECE-205F-7B85-326FD0B81BAD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E60A8558-0E38-47A3-A6E4-5AFAC6E699EB}.Release|Any CPU.Build.0 = Release|Any CPU + {8A517778-9000-4835-9458-4C65E9875BE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A517778-9000-4835-9458-4C65E9875BE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A517778-9000-4835-9458-4C65E9875BE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A517778-9000-4835-9458-4C65E9875BE4}.Release|Any CPU.Build.0 = Release|Any CPU + {459F7F15-603B-4966-9842-E7EDD503F74D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {459F7F15-603B-4966-9842-E7EDD503F74D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {459F7F15-603B-4966-9842-E7EDD503F74D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {459F7F15-603B-4966-9842-E7EDD503F74D}.Release|Any CPU.Build.0 = Release|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBFA08EA-AECE-205F-7B85-326FD0B81BAD}.Release|Any CPU.Build.0 = Release|Any CPU + {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5872AEB3-BAA3-FE31-B0EB-0FB6DBEDCF83}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B1614805-C329-4E81-98FF-6076210F0511} + EndGlobalSection +EndGlobal diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..e90c3036 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ + +services: + caddy: + image: caddy:2-alpine + container_name: yaspo-caddy + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + - caddy_config:/config + depends_on: + - web + - backend + + web: + build: + context: ./web + dockerfile: Dockerfile + container_name: yaspo-web + restart: unless-stopped + environment: + - VITE_API_URL=${API_URL:-http://localhost/api} + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: yaspo-backend + restart: unless-stopped + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ConnectionStrings__DefaultConnection=Server=db,1433;Database=YaspoDb;User Id=sa;Password=${DB_PASSWORD:-StrongP@ssw0rd!};TrustServerCertificate=True; + - JwtSettings__Secret=${JWT_SECRET:-YourSuperSecretKeyNeedToBeLongEnough} + - AWS__ServiceURL=${S3_URL:-http://minio:9000} + - AWS__AccessKey=${S3_ACCESS_KEY:-minioadmin} + - AWS__SecretKey=${S3_SECRET_KEY:-minioadmin} + depends_on: + - db + + db: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: yaspo-db + restart: unless-stopped + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=${DB_PASSWORD:-StrongP@ssw0rd!} + - MSSQL_PID=Express + volumes: + - db_data:/var/opt/mssql + + minio: + image: minio/minio:latest + container_name: yaspo-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=${S3_ACCESS_KEY:-minioadmin} + - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY:-minioadmin} + volumes: + - minio_data:/data + +volumes: + caddy_data: + caddy_config: + db_data: + minio_data: diff --git a/lightsail/cors.json b/lightsail/cors.json deleted file mode 100644 index 9e8101af..00000000 --- a/lightsail/cors.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "rules": [ - { - "allowedOrigins": [ - "*" - ], - "allowedMethods": [ - "PUT", - "GET" - ], - "allowedHeaders": [ - "*" - ], - "maxAgeSeconds": 3000 - } - ] -} diff --git a/mobile/android/app/google-services.json b/mobile/android/app/google-services.json index f3200bcb..d38ddf7f 100644 --- a/mobile/android/app/google-services.json +++ b/mobile/android/app/google-services.json @@ -1,13 +1,13 @@ { "project_info": { - "project_number": "296905451129", - "project_id": "twoaxis-yaspo", - "storage_bucket": "twoaxis-yaspo.firebasestorage.app" + "project_number": "000000000000", + "project_id": "your-project-id", + "storage_bucket": "your-project-id.appspot.com" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:296905451129:android:5af4f6545aa4ed16b4d6a8", + "mobilesdk_app_id": "1:000000000000:android:0000000000000000000000", "android_client_info": { "package_name": "org.twoaxis.yaspo" } @@ -15,7 +15,7 @@ "oauth_client": [], "api_key": [ { - "current_key": "AIzaSyCQ4fQG_3Z-6rgPcQOZi41QKMoE8zkGsP0" + "current_key": "YOUR_API_KEY" } ], "services": { diff --git a/mobile/firebase.json b/mobile/firebase.json index 34b99a12..9e26dfee 100644 --- a/mobile/firebase.json +++ b/mobile/firebase.json @@ -1 +1 @@ -{"flutter":{"platforms":{"android":{"default":{"projectId":"twoaxis-yaspo","appId":"1:296905451129:android:5af4f6545aa4ed16b4d6a8","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"twoaxis-yaspo","appId":"1:296905451129:ios:16fd61cc72e43987b4d6a8","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"twoaxis-yaspo","configurations":{"android":"1:296905451129:android:5af4f6545aa4ed16b4d6a8","ios":"1:296905451129:ios:16fd61cc72e43987b4d6a8"}}}}}} \ No newline at end of file +{} \ No newline at end of file diff --git a/mobile/ios/Runner/GoogleService-Info.plist b/mobile/ios/Runner/GoogleService-Info.plist index f2b9cae9..c2ac4b76 100644 --- a/mobile/ios/Runner/GoogleService-Info.plist +++ b/mobile/ios/Runner/GoogleService-Info.plist @@ -2,18 +2,22 @@ + CLIENT_ID + YOUR_CLIENT_ID.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.YOUR_CLIENT_ID API_KEY - AIzaSyBlXohAdyKIVBFWBpd77zEyCQPqBs-Tzv0 + YOUR_API_KEY GCM_SENDER_ID - 296905451129 + 000000000000 PLIST_VERSION 1 BUNDLE_ID org.twoaxis.yaspo PROJECT_ID - twoaxis-yaspo + your-project-id STORAGE_BUCKET - twoaxis-yaspo.firebasestorage.app + your-project-id.appspot.com IS_ADS_ENABLED IS_ANALYTICS_ENABLED @@ -25,6 +29,6 @@ IS_SIGNIN_ENABLED GOOGLE_APP_ID - 1:296905451129:ios:16fd61cc72e43987b4d6a8 + 1:000000000000:ios:0000000000000000000000 \ No newline at end of file diff --git a/mobile/lib/abstract/auth_service.dart b/mobile/lib/abstract/auth_service.dart index 1c485136..c2746c4d 100644 --- a/mobile/lib/abstract/auth_service.dart +++ b/mobile/lib/abstract/auth_service.dart @@ -1,17 +1,6 @@ import 'package:yaspo_mobile/model/auth/auth_response_model.dart'; abstract class AuthService { - Future signup( - String name, - String email, - String password, - String fcmToken, - ); - Future verifySignup( - String email, - String otp, - String fcmToken, - ); Future login(String email, String password, String fcmToken); Future logout(String refreshToken, String fcmToken); Future refresh(String refreshToken); diff --git a/mobile/lib/abstract/tenant_service.dart b/mobile/lib/abstract/tenant_service.dart deleted file mode 100644 index a9612584..00000000 --- a/mobile/lib/abstract/tenant_service.dart +++ /dev/null @@ -1,10 +0,0 @@ -abstract class TenantService { - Future createTenant(String name, String plan, Map payment); - Future createOrganization(String name, String plan, Map payment); - Future updateTenant(String className); - Future deleteTenant(String password); - Future> getBillingInfo(); - Future updateCard(Map payment); - Future cancelSubscription(); - Future changePlan(String plan); -} diff --git a/mobile/lib/core/api_url.dart b/mobile/lib/core/api_url.dart index d88c8208..e9f25cfd 100644 --- a/mobile/lib/core/api_url.dart +++ b/mobile/lib/core/api_url.dart @@ -1 +1,2 @@ -const String baseUrl = 'https://api.staging.yaspo.twoaxis.org'; +// Replace with your self-hosted server URL +const String baseUrl = 'http://localhost:5000'; diff --git a/mobile/lib/enums/tenant_type.dart b/mobile/lib/enums/tenant_type.dart deleted file mode 100644 index 71ea27b0..00000000 --- a/mobile/lib/enums/tenant_type.dart +++ /dev/null @@ -1 +0,0 @@ -enum TenantType { SINGLE_CLASS, ORGANIZATION } diff --git a/mobile/lib/firebase_options.dart b/mobile/lib/firebase_options.dart index d6527a6e..7bd794d8 100644 --- a/mobile/lib/firebase_options.dart +++ b/mobile/lib/firebase_options.dart @@ -50,19 +50,19 @@ class DefaultFirebaseOptions { } static const FirebaseOptions android = FirebaseOptions( - apiKey: 'AIzaSyCQ4fQG_3Z-6rgPcQOZi41QKMoE8zkGsP0', - appId: '1:296905451129:android:5af4f6545aa4ed16b4d6a8', - messagingSenderId: '296905451129', - projectId: 'twoaxis-yaspo', - storageBucket: 'twoaxis-yaspo.firebasestorage.app', + apiKey: 'YOUR_API_KEY', + appId: 'YOUR_APP_ID', + messagingSenderId: 'YOUR_SENDER_ID', + projectId: 'YOUR_PROJECT_ID', + storageBucket: 'YOUR_STORAGE_BUCKET', ); static const FirebaseOptions ios = FirebaseOptions( - apiKey: 'AIzaSyBlXohAdyKIVBFWBpd77zEyCQPqBs-Tzv0', - appId: '1:296905451129:ios:16fd61cc72e43987b4d6a8', - messagingSenderId: '296905451129', - projectId: 'twoaxis-yaspo', - storageBucket: 'twoaxis-yaspo.firebasestorage.app', + apiKey: 'YOUR_API_KEY', + appId: 'YOUR_APP_ID', + messagingSenderId: 'YOUR_SENDER_ID', + projectId: 'YOUR_PROJECT_ID', + storageBucket: 'YOUR_STORAGE_BUCKET', iosBundleId: 'org.twoaxis.yaspo', ); } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 47ffc50e..b79ddd33 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -15,7 +15,7 @@ import 'package:yaspo_mobile/abstract/notification_service.dart'; import 'package:yaspo_mobile/abstract/quiz_service.dart'; import 'package:yaspo_mobile/abstract/staff_service.dart'; import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/abstract/tenant_service.dart'; + import 'package:yaspo_mobile/abstract/token_storage_service.dart'; import 'package:yaspo_mobile/abstract/user_service.dart'; import 'package:yaspo_mobile/abstract/class_service.dart'; @@ -42,16 +42,16 @@ import 'package:yaspo_mobile/services/remote_notification_service.dart'; import 'package:yaspo_mobile/services/remote_quiz_service.dart'; import 'package:yaspo_mobile/services/remote_staff_service.dart'; import 'package:yaspo_mobile/services/remote_student_service.dart'; -import 'package:yaspo_mobile/services/remote_tenant_service.dart'; + import 'package:yaspo_mobile/services/remote_class_service.dart'; import 'package:yaspo_mobile/services/local_credential_manager_service.dart'; import 'package:yaspo_mobile/services/remote_user_service.dart'; import 'package:yaspo_mobile/theme/color_scheme.dart'; import 'package:yaspo_mobile/ui/app/organization/student/student_organization_host.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff_app_host.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/student_app_host.dart'; + + import 'package:yaspo_mobile/ui/app/organization/staff/staff_organization_host.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; + import 'package:yaspo_mobile/ui/onboarding/onboarding/onboarding_screen.dart'; import 'package:yaspo_mobile/cubit/class/assigned_class_cubit.dart'; import 'package:yaspo_mobile/util/token_util.dart'; @@ -69,7 +69,7 @@ void main() async { GetIt.I.registerSingleton(HiveTokenStorageService()); GetIt.I.registerSingleton(RemoteStudentService()); GetIt.I.registerSingleton(RemoteAuthService()); - GetIt.I.registerSingleton(RemoteTenantService()); + GetIt.I.registerSingleton(RemoteGradeSheetService()); GetIt.I.registerSingleton(RemoteCsvDownloadService()); GetIt.I.registerSingleton(RemoteAnnouncementService()); diff --git a/mobile/lib/model/tenant/tenant.dart b/mobile/lib/model/tenant/tenant.dart deleted file mode 100644 index b661cbb1..00000000 --- a/mobile/lib/model/tenant/tenant.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:yaspo_mobile/enums/tenant_type.dart'; - -class Tenant { - final String id; - final String name; - final TenantType type; - final String? plan; - - Tenant({ - required this.id, - required this.name, - required this.type, - this.plan, - }); - - factory Tenant.fromJson(Map json) { - return Tenant( - id: json['id']?.toString() ?? '', - name: json['name']?.toString() ?? '', - plan: json['plan']?.toString(), - type: TenantType.values.firstWhere( - (e) => e.name.toUpperCase() == json['type'].toString().toUpperCase(), - orElse: () => TenantType.ORGANIZATION, - ), - ); - } - - Map toJson() { - return {'id': id, 'name': name, 'type': type.name, 'plan': plan}; - } -} diff --git a/mobile/lib/model/user/user.dart b/mobile/lib/model/user/user.dart index 5a3755f0..a84f8a5c 100644 --- a/mobile/lib/model/user/user.dart +++ b/mobile/lib/model/user/user.dart @@ -1,10 +1,8 @@ import 'dart:convert'; import 'package:yaspo_mobile/enums/permission.dart'; import 'package:yaspo_mobile/enums/user_type.dart'; -import 'package:yaspo_mobile/model/tenant/tenant.dart'; class User { - final Tenant? tenant; final String name; final String email; final UserType type; @@ -12,7 +10,6 @@ class User { final List permissions; User({ - required this.tenant, required this.name, required this.email, required this.type, @@ -50,7 +47,6 @@ class User { } return User( - tenant: tenantMap == null ? null : Tenant.fromJson(tenantMap), name: json['name'] ?? '', email: json['email'] ?? '', id: json['sub'] ?? '', @@ -69,7 +65,6 @@ class User { Map toJson() { return { - 'tenant': tenant?.toJson(), 'name': name, 'email': email, 'sub': id, diff --git a/mobile/lib/services/remote_tenant_service.dart b/mobile/lib/services/remote_tenant_service.dart deleted file mode 100644 index f352fc85..00000000 --- a/mobile/lib/services/remote_tenant_service.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:yaspo_mobile/abstract/tenant_service.dart'; -import 'package:yaspo_mobile/core/authenticated_dio.dart'; - -class RemoteTenantService extends TenantService { - @override - Future createTenant(String name, String plan, Map payment) async { - var response = await authenticatedDio.put( - '/tenant/single', - data: {"name": name, "plan": plan, "payment": payment}, - ); - if (response.statusCode != 200) { - throw Exception('Failed to add tenant.'); - } - } - - @override - Future createOrganization(String name, String plan, Map payment) async { - var response = await authenticatedDio.put( - '/tenant/organization', - data: {"name": name, "plan": plan, "payment": payment}, - ); - if (response.statusCode != 200) { - throw Exception('Failed to create organization.'); - } - } - - @override - Future updateTenant(String className) async { - var response = await authenticatedDio.post( - '/tenant', - data: {"name": className}, - ); - if (response.statusCode != 200) { - throw Exception('Failed to update tenant.'); - } - } - - @override - Future deleteTenant(String password) async { - var response = await authenticatedDio.delete( - '/tenant', - data: {"password": password}, - ); - if (response.statusCode != 200) { - throw Exception('Failed to delete tenant.'); - } - } - - @override - Future> getBillingInfo() async { - var response = await authenticatedDio.get('/tenant/billing'); - if (response.statusCode != 200) { - throw Exception('Failed to get billing info.'); - } - return response.data as Map; - } - - @override - Future updateCard(Map payment) async { - var response = await authenticatedDio.post('/tenant/billing/card', data: payment); - if (response.statusCode != 200) { - throw Exception('Failed to update card.'); - } - } - - @override - Future cancelSubscription() async { - var response = await authenticatedDio.post('/tenant/billing/cancel'); - if (response.statusCode != 200) { - throw Exception('Failed to cancel subscription.'); - } - } - - @override - Future changePlan(String plan) async { - var response = await authenticatedDio.post('/tenant/billing/plan', data: {"plan": plan}); - if (response.statusCode != 200) { - throw Exception('Failed to change plan.'); - } - } -} diff --git a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart b/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart deleted file mode 100644 index 5a0ec738..00000000 --- a/mobile/lib/ui/app/single-class/staff/announcements/add_announcement_screen.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/announcement_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddAnnouncementScreen extends StatefulWidget { - const AddAnnouncementScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AddAnnouncementScreenState(); -} - -class _AddAnnouncementScreenState extends State { - final TextEditingController addController = TextEditingController(); - final GlobalKey formKey = GlobalKey(); - bool isButtonEnabled = false; - bool isSubmitting = false; - - @override - void initState() { - super.initState(); - addController.addListener(checkInput); - } - - void checkInput() { - setState(() { - isButtonEnabled = addController.text.trim().isNotEmpty; - }); - } - - @override - void dispose() { - addController.dispose(); - super.dispose(); - } - - Future submitAnnouncement() async { - if (!formKey.currentState!.validate()) return; - setState(() => isSubmitting = true); - try { - var newAnnouncement = await GetIt.I() - .addAnnouncement( - context.read().state!.name, - addController.text.trim(), - widget.classId, - ); - await GetIt.I().trackEvent( - 'add_announcements', - parameters: {'status': 'success'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Announcement added successfully", - onComplete: (_) { - Navigator.pop(context, newAnnouncement); - }, - ); - } catch (e) { - await GetIt.I().trackEvent( - 'add_announcement', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Failed to add announcement", - onComplete: (_) {}, - ); - } - } finally { - setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'New Announcement', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 20), - Form( - key: formKey, - child: TextFormField( - key: Key("add_announcement_button"), - controller: addController, - textInputAction: TextInputAction.newline, - maxLines: 13, - decoration: InputDecoration( - hintText: 'Whats on your mind?', - hintStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w400, - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - width: 1.0, - ), - borderRadius: BorderRadius.circular(8.0), - ), - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - width: 1.0, - ), - borderRadius: BorderRadius.circular(8.0), - ), - errorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 1.0, - ), - borderRadius: BorderRadius.circular(8.0), - ), - focusedErrorBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.primary, - width: 1.0, - ), - borderRadius: BorderRadius.circular(8.0), - ), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter the new announcement'; - } - return null; - }, - ), - ), - ], - ), - ), - - PrimaryButton( - text: isSubmitting ? "Posting..." : "Post Announcement", - onPressed: submitAnnouncement, - enabled: isButtonEnabled && !isSubmitting, - ), - SizedBox(height: 30), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart b/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart deleted file mode 100644 index 7bc79b49..00000000 --- a/mobile/lib/ui/app/single-class/staff/announcements/announcement_widget.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/announcement_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/announcement/announcement.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/options/option_tile.dart'; -import 'package:yaspo_mobile/ui/common/options/options_item.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/util/time_format.dart'; - -class AnnouncementWidget extends StatelessWidget { - const AnnouncementWidget({ - super.key, - required this.announcement, - required this.onDelete, - this.classId = 'single', - }); - - final Announcement announcement; - final VoidCallback onDelete; - final String classId; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 40, - height: 40, - fit: BoxFit.contain, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - announcement.authorName, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - formatTimeAgo(createdAt: announcement.createdAt), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - BlocBuilder( - builder: (BuildContext context, User? user) { - if (user == null) { - return const SizedBox.shrink(); - } - if (user.permissions.contains(Permission.EDUCATOR)) { - return OptionsItem( - iconKey: Key("${announcement.id}-options"), - options: [ - OptionTile( - optionKey: Key("${announcement.id}-delete"), - label: 'Delete Announcement', - icon: Icons.delete, - onTap: () async { - var confirm = await showCustomDialog( - context: context, - title: - "Are you sure you want to delete this announcement?", - message: "This action cannot be undone.", - confirmText: "Delete", - ); - if (confirm == null || !confirm.$1) return; - try { - await GetIt.I() - .deleteAnnouncement( - announcement.id, - classId, - ); - await GetIt.I().trackEvent( - 'delete_announcements', - parameters: {'status': 'success'}, - ); - onDelete(); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of( - context, - ).colorScheme.tertiary, - text: "Announcement deleted successfully", - onComplete: (_) {}, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'delete_announcements', - parameters: {'status': 'failed'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of( - context, - ).colorScheme.primary, - text: "Failed to delete announcement", - onComplete: (_) {}, - ); - } - } - }, - ), - ], - ); - } - return SizedBox(); - }, - ), - ], - ), - const SizedBox(height: 10), - Text( - announcement.content, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/announcements/announcements_header.dart b/mobile/lib/ui/app/single-class/staff/announcements/announcements_header.dart deleted file mode 100644 index e2b61a3a..00000000 --- a/mobile/lib/ui/app/single-class/staff/announcements/announcements_header.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/add_announcement_screen.dart'; - -class AnnouncementsHeader extends StatelessWidget { - final Function addAnnouncement; - final String classId; - - const AnnouncementsHeader({ - super.key, - required this.addAnnouncement, - this.classId = 'single', - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Announcements', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - BlocBuilder( - builder: (context, user) { - if (user?.permissions.contains(Permission.EDUCATOR) ?? false) { - return IconButton( - icon: Icon( - Icons.add, - size: 32, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - var newAnnouncement = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AddAnnouncementScreen(classId: classId), - ), - ); - - if (newAnnouncement != null) { - addAnnouncement(newAnnouncement); - } - }, - ); - } - return const SizedBox(); - }, - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart b/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart deleted file mode 100644 index 7d357998..00000000 --- a/mobile/lib/ui/app/single-class/staff/announcements/announcements_screen.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/announcement_service.dart'; -import 'package:yaspo_mobile/model/announcement/announcement.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcement_widget.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcements_header.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AnnouncementScreen extends StatefulWidget { - const AnnouncementScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AnnouncementScreenState(); -} - -class _AnnouncementScreenState extends State { - bool loading = false; - bool error = false; - List announcements = []; - - @override - void initState() { - super.initState(); - fetchAnnouncements(); - } - - Future fetchAnnouncements() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getAnnouncements(widget.classId); - await GetIt.I().trackEvent( - 'fetch_announcements', - parameters: {'type': 'staff', 'status': 'success'}, - ); - if (mounted) { - setState(() { - announcements = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_announcements', - parameters: {'type': 'staff', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - void _addAnnouncement(Announcement announcement) { - setState(() { - announcements.insert(0, announcement); - }); - } - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - AnnouncementsHeader( - addAnnouncement: _addAnnouncement, - classId: widget.classId, - ), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchAnnouncements, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ); - } - - Widget _buildContent() { - if (loading && announcements.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const AnnouncementSkeleton(), - ); - } - - if (error && announcements.isEmpty) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "Failed to load announcements.\nPlease try again later.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ); - }, - ); - } - - if (announcements.isEmpty) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "No announcements available.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ); - }, - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: announcements.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - var announcement = announcements[index]; - return AnnouncementWidget( - announcement: announcement, - classId: widget.classId, - onDelete: () { - setState(() { - announcements.removeWhere((a) => a.id == announcement.id); - }); - }, - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/assignment_header.dart b/mobile/lib/ui/app/single-class/staff/assignments/assignment_header.dart deleted file mode 100644 index 7217a3f1..00000000 --- a/mobile/lib/ui/app/single-class/staff/assignments/assignment_header.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/assignments/create_assignment_screen.dart'; - -class AssignmentHeader extends StatelessWidget { - const AssignmentHeader({super.key, required this.createAssignment, this.classId = 'single'}); - final Function createAssignment; - final String classId; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Assignments', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - BlocBuilder( - builder: (context, user) { - if (user?.permissions.contains(Permission.EDUCATOR) ?? false) { - return IconButton( - icon: Icon( - Icons.add, - size: 32, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - var newAssignment = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => CreateAssignmentScreen(classId: classId)), - ); - - if (newAssignment != null) { - createAssignment(newAssignment); - } - }, - ); - } - return const SizedBox(); - }, - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart b/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart deleted file mode 100644 index 713e3c4f..00000000 --- a/mobile/lib/ui/app/single-class/staff/assignments/assignment_widget.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/assignments/update_assignment_screen.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/options/option_tile.dart'; -import 'package:yaspo_mobile/ui/common/options/options_item.dart'; - -class AssignmentWidget extends StatelessWidget { - const AssignmentWidget({ - super.key, - required this.assignment, - required this.onDelete, - required this.onUpdate, - this.classId = 'single', - }); - - final Assignment assignment; - final VoidCallback onDelete; - final Function onUpdate; - final String classId; - - DateTime _parseDueDate() { - try { - return DateTime.parse(assignment.dueDate); - } catch (_) { - return DateFormat('dd/MM/yyyy').parse(assignment.dueDate.trim()); - } - } - - String _calculateStatus() { - var due = _parseDueDate(); - var now = DateTime.now(); - var diff = due.difference(now).inDays; - if (diff <= 0) return 'Done'; - if (diff < 7) { - return 'Due in $diff ${diff == 1 ? 'day' : 'days'}'; - } - var weeks = (diff / 7).ceil(); - return 'Due in $weeks ${weeks == 1 ? 'week' : 'weeks'}'; - } - - Color _getStatusColor(BuildContext context) { - var diff = _parseDueDate().difference(DateTime.now()).inDays; - if (diff <= 0) return Theme.of(context).colorScheme.tertiary; - if (diff < 7) return Theme.of(context).colorScheme.primary; - return Theme.of(context).colorScheme.onSurface; - } - - @override - Widget build(BuildContext context) { - var statusText = _calculateStatus(); - var statusColor = _getStatusColor(context); - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - assignment.title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const SizedBox(width: 10), - Text( - statusText, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: statusColor, - ), - ), - const SizedBox(width: 8), - BlocBuilder( - builder: (context, user) { - if (user == null) return const SizedBox.shrink(); - if (!user.permissions.contains(Permission.EDUCATOR)) { - return const SizedBox.shrink(); - } - return OptionsItem( - iconKey: Key('${assignment.id}-options'), - options: [ - OptionTile( - optionKey: Key('${assignment.id}-delete'), - label: 'Delete Assignment', - icon: Icons.delete, - onTap: () async { - var confirm = await showCustomDialog( - context: context, - title: - 'Are you sure you want to delete this assignment?', - message: 'This action cannot be undone.', - confirmText: 'Delete', - ); - if (confirm == null || !confirm.$1) return; - try { - await GetIt.I().deleteAssignment( - assignment.id, - classId, - ); - onDelete(); - await GetIt.I().trackEvent( - 'delete_assignment', - parameters: {'status': 'success'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Assignment deleted successfully', - onComplete: (_) {}, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'delete_assignment', - parameters: {'status': 'failed'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to delete assignment', - onComplete: (_) {}, - ); - } - } - }, - ), - OptionTile( - optionKey: Key("${assignment.id}-update"), - label: 'Update Assignment', - icon: Icons.edit_outlined, - onTap: () async { - var updateAssignment = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - UpdateAssignmentScreen(assignments: assignment, classId: classId), - ), - ); - if (updateAssignment != null && context.mounted) { - onUpdate(updateAssignment); - } - }, - ), - ], - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/assignments_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/assignments_screen.dart deleted file mode 100644 index 926ca554..00000000 --- a/mobile/lib/ui/app/single-class/staff/assignments/assignments_screen.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/theme/color_scheme.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/assignments/assignment_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/assignments/assignment_widget.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AssignmentsScreen extends StatefulWidget { - const AssignmentsScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AssignmentsScreenState(); -} - -class _AssignmentsScreenState extends State { - bool loading = false; - bool error = false; - List assignments = []; - - @override - void initState() { - super.initState(); - fetchAssignments(); - } - - Future fetchAssignments() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getAssignment(widget.classId); - await GetIt.I().trackEvent( - 'fetch_assignment', - parameters: {'type': 'staff', 'status': 'success'}, - ); - if (mounted) { - setState(() { - assignments = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_assignment', - parameters: {'type': 'staff', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - void _createAssignment(Assignment assignment) async { - setState(() { - assignments.insert(0, assignment); - }); - await GetIt.I().trackEvent( - 'create_assignment', - parameters: {'status': 'success'}, - ); - } - - void _updateAssignment(Assignment updatedAssignment) async { - var assignmentIndex = assignments.indexWhere( - (s) => s.id == updatedAssignment.id, - ); - if (assignmentIndex != -1) { - setState(() { - assignments[assignmentIndex] = updatedAssignment; - }); - } - await GetIt.I().trackEvent( - 'update_assignment', - parameters: {'status': 'success'}, - ); - } - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - AssignmentHeader(createAssignment: _createAssignment, classId: widget.classId), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchAssignments, - color: colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ); - } - - Widget _buildContent() { - if (loading && assignments.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const AssignmentSkeleton(), - ); - } - - if (error && assignments.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "Failed to load assignments.\nPlease try again later.", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - if (assignments.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "No assignments yet. Create one!", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: assignments.length, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) { - var assignment = assignments[index]; - return AssignmentWidget( - assignment: assignment, - classId: widget.classId, - onDelete: () { - setState(() { - assignments.removeWhere((a) => a.id == assignment.id); - }); - }, - onUpdate: _updateAssignment, - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart deleted file mode 100644 index d08f4a15..00000000 --- a/mobile/lib/ui/app/single-class/staff/assignments/create_assignment_screen.dart +++ /dev/null @@ -1,297 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class CreateAssignmentScreen extends StatefulWidget { - const CreateAssignmentScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _CreateAssignmentScreenState(); -} - -class _CreateAssignmentScreenState extends State { - final TextEditingController assignmentController = TextEditingController(); - final TextEditingController descriptionController = TextEditingController(); - final TextEditingController dueDateController = TextEditingController(); - final TextEditingController totalMarksController = TextEditingController(); - - final GlobalKey formKey = GlobalKey(); - - bool visible = true; - bool isButtonEnabled = false; - bool isSubmitting = false; - - DateTime? _selectedDueDate; - - @override - void initState() { - super.initState(); - assignmentController.addListener(checkInput); - descriptionController.addListener(checkInput); - dueDateController.addListener(checkInput); - totalMarksController.addListener(checkInput); - } - - void checkInput() { - setState(() { - isButtonEnabled = - assignmentController.text.trim().isNotEmpty && - descriptionController.text.trim().isNotEmpty && - dueDateController.text.trim().isNotEmpty && - totalMarksController.text.trim().isNotEmpty && - _selectedDueDate != null; - }); - } - - @override - void dispose() { - assignmentController.dispose(); - descriptionController.dispose(); - dueDateController.dispose(); - totalMarksController.dispose(); - super.dispose(); - } - - Future _pickDate(BuildContext context) async { - DateTime? pickedDate = await showDatePicker( - context: context, - initialDate: DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime(2100), - builder: (context, child) { - return Theme( - data: Theme.of(context).copyWith( - colorScheme: ColorScheme.light( - primary: Theme.of(context).colorScheme.primary, - onPrimary: Theme.of(context).colorScheme.surface, - secondary: Theme.of(context).colorScheme.secondary, - surface: Theme.of(context).colorScheme.surface, - onSurface: Theme.of(context).colorScheme.onSurface, - ), - textButtonTheme: TextButtonThemeData( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - ), - ), - child: child!, - ); - }, - ); - - if (pickedDate != null) { - if (!context.mounted) return; - TimeOfDay? pickedTime = await showTimePicker( - context: context, - initialTime: TimeOfDay.now(), - ); - - if (pickedTime != null) { - DateTime picked = DateTime( - pickedDate.year, - pickedDate.month, - pickedDate.day, - pickedTime.hour, - pickedTime.minute, - ); - - if (picked.isBefore(DateTime.now())) { - if (!context.mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Due date cannot be in the past', - onComplete: (_) {}, - ); - return; - } - - _selectedDueDate = picked; - dueDateController.text = - '${picked.day.toString().padLeft(2, '0')}/' - '${picked.month.toString().padLeft(2, '0')}/' - '${picked.year} ' - '${pickedTime.hour.toString().padLeft(2, '0')}:' - '${pickedTime.minute.toString().padLeft(2, '0')}'; - checkInput(); - } - } - } - - Future submitAssignment() async { - if (!formKey.currentState!.validate()) return; - - setState(() => isSubmitting = true); - - try { - var user = context.read().state!; - var dueDate = _selectedDueDate!.toIso8601String(); - - var newAssignment = await GetIt.I().addAssignment( - assignmentController.text.trim(), - descriptionController.text.trim(), - int.parse(totalMarksController.text.trim()), - dueDate, - user.name, - visible, - widget.classId, - ); - - await GetIt.I().trackEvent( - 'add_assignment', - parameters: {'status': 'success'}, - ); - - if (!mounted) return; - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Assignment created successfully', - onComplete: (_) { - Navigator.pop(context, newAssignment); - }, - ); - } catch (_) { - if (mounted) { - await GetIt.I().trackEvent( - 'update_assignment', - parameters: {'status': 'failed'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to create assignment', - onComplete: (_) {}, - ); - } - } finally { - setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Expanded( - child: Text( - 'Create New Assignment', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - Expanded( - child: SingleChildScrollView( - child: Form( - key: formKey, - child: Column( - children: [ - ThemedInputField( - labelText: 'Assignment Title', - hintText: 'Algebra Worksheet 1', - controller: assignmentController, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Description', - hintText: 'Assignment Content', - controller: descriptionController, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.multiline, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Due Date', - hintText: '10/10/2025', - controller: dueDateController, - textInputAction: TextInputAction.next, - suffixIcon: Icons.calendar_today_outlined, - readonly: true, - ontap: () => _pickDate(context), - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Total Marks', - hintText: '100', - controller: totalMarksController, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.number, - ), - const SizedBox(height: 10), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Visible", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: visible, - onChanged: (value) { - setState(() { - visible = value!; - }); - }, - ), - ], - ), - ), - ), - ), - PrimaryButton( - text: isSubmitting ? 'Creating...' : 'Create Assignment', - onPressed: submitAssignment, - enabled: isButtonEnabled && !isSubmitting, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart b/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart deleted file mode 100644 index 150c0a18..00000000 --- a/mobile/lib/ui/app/single-class/staff/assignments/update_assignment_screen.dart +++ /dev/null @@ -1,342 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/exceptions/assignment_exception.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class UpdateAssignmentScreen extends StatefulWidget { - const UpdateAssignmentScreen({super.key, required this.assignments, this.classId = 'single'}); - - final Assignment assignments; - final String classId; - - @override - State createState() => _UpdateAssignmentScreenState(); -} - -class _UpdateAssignmentScreenState extends State { - final assignmentController = TextEditingController(); - final descriptionController = TextEditingController(); - final dueDateController = TextEditingController(); - final totalMarksController = TextEditingController(); - final formKey = GlobalKey(); - bool visible = true; - bool isButtonEnabled = false; - bool isSubmitting = false; - bool isSubmitted = false; - DateTime? _selectedDueDate; - late String _initialTitle; - late String _initialDescription; - late String _initialDueDate; - late String _initialMaxGrade; - - @override - void initState() { - super.initState(); - var assignment = widget.assignments; - _initialTitle = assignment.title; - _initialDescription = assignment.description; - _initialMaxGrade = assignment.maxGrade.toString(); - visible = assignment.isVisible; - - DateTime due; - try { - due = DateTime.parse(assignment.dueDate); - } catch (_) { - due = DateFormat('dd/MM/yyyy').parse(assignment.dueDate.trim()); - } - - _selectedDueDate = due; - _initialDueDate = DateFormat('dd/MM/yyyy HH:mm').format(due); - - assignmentController.text = _initialTitle; - descriptionController.text = _initialDescription; - totalMarksController.text = _initialMaxGrade; - dueDateController.text = _initialDueDate; - - assignmentController.addListener(checkInput); - descriptionController.addListener(checkInput); - dueDateController.addListener(checkInput); - totalMarksController.addListener(checkInput); - } - - void checkInput() { - bool hasChanges = - assignmentController.text.trim() != _initialTitle || - descriptionController.text.trim() != _initialDescription || - totalMarksController.text.trim() != _initialMaxGrade || - dueDateController.text.trim() != _initialDueDate || - visible != widget.assignments.isVisible; - bool allFilled = - assignmentController.text.trim().isNotEmpty && - descriptionController.text.trim().isNotEmpty && - totalMarksController.text.trim().isNotEmpty && - dueDateController.text.trim().isNotEmpty; - setState(() { - isButtonEnabled = hasChanges && allFilled; - }); - } - - @override - void dispose() { - assignmentController.dispose(); - descriptionController.dispose(); - dueDateController.dispose(); - totalMarksController.dispose(); - super.dispose(); - } - - Future _pickDate(BuildContext context) async { - var pickedDate = await showDatePicker( - context: context, - initialDate: _selectedDueDate ?? DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime(2100), - ); - if (pickedDate != null) { - if (!context.mounted) return; - TimeOfDay? pickedTime = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_selectedDueDate ?? DateTime.now()), - ); - - if (pickedTime != null) { - DateTime picked = DateTime( - pickedDate.year, - pickedDate.month, - pickedDate.day, - pickedTime.hour, - pickedTime.minute, - ); - - if (picked.isBefore(DateTime.now())) { - if (!context.mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Due date cannot be in the past', - onComplete: (_) {}, - ); - return; - } - - _selectedDueDate = picked; - dueDateController.text = - '${picked.day.toString().padLeft(2, '0')}/${picked.month.toString().padLeft(2, '0')}/${picked.year} ' - '${pickedTime.hour.toString().padLeft(2, '0')}:${pickedTime.minute.toString().padLeft(2, '0')}'; - checkInput(); - } - } - } - - Future submitAssignment() async { - setState(() => isSubmitted = true); - if (!formKey.currentState!.validate()) return; - setState(() => isSubmitting = true); - try { - await GetIt.I().updateAssignment( - widget.assignments.id, - assignmentController.text.trim(), - descriptionController.text.trim(), - int.parse(totalMarksController.text.trim()), - _selectedDueDate!.toIso8601String(), - visible, - widget.classId, - ); - await GetIt.I().trackEvent( - 'update_assignment', - parameters: {'status': 'success'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Assignment updated successfully', - onComplete: (_) { - Navigator.pop( - context, - Assignment( - id: widget.assignments.id, - title: assignmentController.text.trim(), - description: descriptionController.text.trim(), - maxGrade: int.parse(totalMarksController.text.trim()), - dueDate: dueDateController.text.trim(), - author: widget.assignments.author, - isVisible: visible, - ), - ); - Navigator.pop(context); - }, - ); - } on AssignmentNotFound { - if (mounted) { - await GetIt.I().trackEvent( - 'update_assignment', - parameters: {'status': 'failed', 'error': 'assignment_not_found'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Assignment not found', - onComplete: (_) {}, - ); - } - } catch (_) { - if (mounted) { - await GetIt.I().trackEvent( - 'update_assignment', - parameters: {'status': 'failed', 'error': 'unknown_error'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to update assignment', - onComplete: (_) {}, - ); - } - } finally { - setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Expanded( - child: Text( - 'Edit Assignment', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - Expanded( - child: SingleChildScrollView( - child: Form( - key: formKey, - autovalidateMode: isSubmitted - ? AutovalidateMode.always - : AutovalidateMode.disabled, - child: Column( - children: [ - ThemedInputField( - labelText: 'Assignment Title', - hintText: 'Algebra Worksheet 1', - controller: assignmentController, - textInputAction: TextInputAction.next, - validator: (val) => - val!.trim().isEmpty ? 'Title is required' : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Description', - hintText: 'Assignment Content', - controller: descriptionController, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.multiline, - validator: (val) => val!.trim().isEmpty - ? 'Description is required' - : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Due Date', - hintText: '10/10/2025', - controller: dueDateController, - textInputAction: TextInputAction.next, - suffixIcon: Icons.calendar_today_outlined, - readonly: true, - ontap: () => _pickDate(context), - validator: (val) => val!.trim().isEmpty - ? 'Due date is required' - : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Total Marks', - hintText: '100', - controller: totalMarksController, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.number, - validator: (val) => val!.trim().isEmpty - ? 'Total marks required' - : null, - ), - const SizedBox(height: 10), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Visible", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: visible, - onChanged: (value) { - setState(() { - visible = value!; - checkInput(); - }); - }, - ), - ], - ), - ), - ), - ), - PrimaryButton( - text: isSubmitting ? 'Updating...' : 'Update Assignment', - onPressed: submitAssignment, - enabled: isButtonEnabled && !isSubmitting, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart b/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart deleted file mode 100644 index 507c9897..00000000 --- a/mobile/lib/ui/app/single-class/staff/class_settings/class_info_screen.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/tenant_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/tenant/tenant.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/class_settings/delete_class_screen.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ClassInfoScreen extends StatefulWidget { - const ClassInfoScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _ClassInfoScreenState(); -} - -class _ClassInfoScreenState extends State { - GlobalKey formKey = GlobalKey(); - TextEditingController textFieldController = TextEditingController(); - - bool isButtonEnabled = false; - bool isSubmitting = false; - - void checkInput() { - setState(() { - isButtonEnabled = textFieldController.text.trim().isNotEmpty; - }); - } - - Future updateTenant() async { - if (!formKey.currentState!.validate()) return; - - setState(() => isSubmitting = true); - - try { - await GetIt.I().updateTenant( - textFieldController.text.trim(), - ); - - await GetIt.I().trackEvent( - 'update_class', - parameters: {'status': 'success'}, - ); - - if (mounted) { - var userCubit = context.read(); - var currentUser = userCubit.state; - - var updatedUser = User( - tenant: Tenant( - id: currentUser!.tenant!.id, - name: textFieldController.text.trim(), - type: currentUser.tenant!.type, - ), - name: currentUser.name, - email: currentUser.email, - type: currentUser.type, - id: currentUser.id, - permissions: currentUser.permissions, - ); - - userCubit.setUserModel(updatedUser); - - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Updated class name successfully", - onComplete: (_) {}, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'update_class', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } - } finally { - setState(() => isSubmitting = false); - } - } - - @override - void initState() { - textFieldController.addListener(checkInput); - var user = context.read().state; - var tenantName = user?.tenant?.name ?? ''; - textFieldController.text = tenantName; - super.initState(); - } - - @override - void dispose() { - textFieldController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'Class Info', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - SizedBox(height: 27), - BlocBuilder( - builder: (context, state) { - return ThemedInputField( - controller: textFieldController, - textInputAction: TextInputAction.next, - labelText: "Name", - hintText: 'Class Name', - validator: (input) { - if (input == null || input.isEmpty) { - return 'Class name is required.'; - } - return null; - }, - ); - }, - ), - SizedBox(height: 59), - Center( - child: TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => DeleteClassScreen(), - ), - ); - }, - child: Text( - 'Delete class permanently.', - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 15, - ), - ), - ), - ), - ], - ), - ), - PrimaryButton( - text: isSubmitting ? "Saving..." : "Save Changes", - onPressed: updateTenant, - enabled: isButtonEnabled && !isSubmitting, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_header.dart b/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_header.dart deleted file mode 100644 index 28f30799..00000000 --- a/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_header.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; - -class ClassSettingsHeader extends StatelessWidget { - const ClassSettingsHeader({super.key}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'Class Settings', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_screen.dart b/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_screen.dart deleted file mode 100644 index aa148034..00000000 --- a/mobile/lib/ui/app/single-class/staff/class_settings/class_settings_screen.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/class_settings/class_info_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/class_settings/class_settings_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/class_settings/settings_tile.dart'; -import 'package:yaspo_mobile/ui/common/billing/billing_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ClassSettingsScreen extends StatelessWidget { - const ClassSettingsScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClassSettingsHeader(), - const SizedBox(height: 15), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsTile( - title: 'Info', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return ClassInfoScreen(classId: classId); - }, - ), - ); - }, - ), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 2, - ), - SettingsTile( - title: 'Billing', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const BillingScreen(isOrganization: false), - ), - ); - } - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart b/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart deleted file mode 100644 index e4da93cd..00000000 --- a/mobile/lib/ui/app/single-class/staff/class_settings/delete_class_screen.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:hive/hive.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/tenant_service.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/onboarding/login/login_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class DeleteClassScreen extends StatefulWidget { - const DeleteClassScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _DeleteClassScreenState(); -} - -class _DeleteClassScreenState extends State { - TextEditingController textFieldController = TextEditingController(); - - GlobalKey formKey = GlobalKey(); - bool isButtonEnabled = false; - bool isSubmitting = false; - - void checkInput() { - setState(() { - isButtonEnabled = textFieldController.text.trim().isNotEmpty; - }); - } - - @override - void initState() { - textFieldController.addListener(checkInput); - super.initState(); - } - - @override - void dispose() { - textFieldController.dispose(); - super.dispose(); - } - - Future deleteTenant() async { - if (!formKey.currentState!.validate()) return; - - setState(() => isSubmitting = true); - - try { - await GetIt.I().deleteTenant( - textFieldController.text.trim(), - ); - - await GetIt.I().trackEvent( - 'delete_class', - parameters: {'status': 'success'}, - ); - - var tokenBox = await Hive.openBox('token'); - await tokenBox.delete('access_token'); - await tokenBox.delete('refresh_token'); - - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Tenant Deleted successfully", - onComplete: (_) {}, - ); - } - if (mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => LoginScreen()), - (Route route) => false, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'delete_class', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Failed to delete tenant", - onComplete: (_) {}, - ); - } - } finally { - setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'Delete Class', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - SizedBox(height: 27), - Center( - child: Text( - 'All assignments, quizzes, grade sheets and\naccounts will be deleted.\nThis action cannot be undone. ', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 18, - ), - ), - ), - SizedBox(height: 48), - const SizedBox(height: 5), - ThemedInputField( - labelText: "Password", - controller: textFieldController, - textInputAction: TextInputAction.next, - hintText: '••••••••••', - validator: (input) { - if (input == null || input.isEmpty) { - return 'Password is required.'; - } - return null; - }, - ), - ], - ), - ), - PrimaryButton( - text: isSubmitting ? "Deleting..." : "Delete Class", - onPressed: deleteTenant, - enabled: isButtonEnabled && !isSubmitting, - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/class_settings/settings_tile.dart b/mobile/lib/ui/app/single-class/staff/class_settings/settings_tile.dart deleted file mode 100644 index cafc9310..00000000 --- a/mobile/lib/ui/app/single-class/staff/class_settings/settings_tile.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -class SettingsTile extends StatelessWidget { - const SettingsTile({super.key, required this.title, required this.onTap}); - - final String title; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15.0, vertical: 12.0), - child: Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart deleted file mode 100644 index a4eccc86..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddGradeSheetScreen extends StatefulWidget { - const AddGradeSheetScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AddGradeSheetScreenState(); -} - -class _AddGradeSheetScreenState extends State { - final formKey = GlobalKey(); - final TextEditingController gradeNameController = TextEditingController(); - - bool isVisible = false; - bool isSubmitting = false; - - bool get isFormValid => gradeNameController.text.trim().isNotEmpty; - - @override - void initState() { - super.initState(); - gradeNameController.addListener(() => setState(() {})); - } - - Future submit() async { - if (!formKey.currentState!.validate()) return; - - setState(() => isSubmitting = true); - - try { - var newGradeSheet = await GetIt.I().addGradeSheet( - gradeNameController.text.trim(), - isVisible, - widget.classId, - ); - await GetIt.I().trackEvent( - 'add_grade_sheet', - parameters: {'sheet_name': gradeNameController.text.trim()}, - ); - await GetIt.I().trackEvent( - 'add_grade_sheet', - parameters: {'status': 'success'}, - ); - if (!mounted) return; - context.read().addGradeSheet(newGradeSheet); - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Added grade sheet successfully", - onComplete: (_) { - Navigator.pop(context); - }, - ); - } catch (e) { - await GetIt.I().trackEvent( - 'add_grade_sheet', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void dispose() { - gradeNameController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'New Grade Sheet', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - - const SizedBox(height: 30), - - ThemedInputField( - controller: gradeNameController, - labelText: "Grade Sheet Name", - textInputAction: TextInputAction.done, - hintText: "Math — Quiz 2 Grades", - ), - - const SizedBox(height: 8), - - SizedBox( - width: 385, - child: CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.8), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Visible", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: isVisible, - onChanged: (v) => setState(() => isVisible = v!), - ), - ), - ], - ), - ), - ), - PrimaryButton( - text: isSubmitting ? "Saving..." : "Save Grade Sheet", - enabled: !isSubmitting && isFormValid, - onPressed: submit, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_header.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_header.dart deleted file mode 100644 index 8639eda1..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_header.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/add_grade_sheet_screen.dart'; - -class GradeSheetsHeader extends StatelessWidget { - const GradeSheetsHeader({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'Grade Sheets', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - IconButton( - icon: Icon( - Icons.add, - size: 32, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AddGradeSheetScreen(classId: classId), - ), - ); - }, - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart deleted file mode 100644 index b4c6943f..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/grade_sheets_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/grade_sheets_records_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class GradeSheets extends StatefulWidget { - const GradeSheets({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _GradeSheetsState(); -} - -class _GradeSheetsState extends State { - bool loading = false; - bool error = false; - - @override - void initState() { - super.initState(); - fetchGradeSheets(); - } - - Future fetchGradeSheets() async { - if (!mounted) return; - setState(() { - loading = true; - error = false; - }); - - try { - var sheets = await GetIt.I().getGradeSheets( - widget.classId, - ); - await GetIt.I().trackEvent( - 'fetch_grade_sheets', - parameters: {'type': 'staff', 'status': 'success'}, - ); - if (mounted) { - context.read().setGradeSheets(sheets); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_grade_sheets', - parameters: {'type': 'staff', 'status': 'failed'}, - ); - if (mounted) setState(() => error = true); - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - GradeSheetsHeader(classId: widget.classId), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchGradeSheets, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent() { - var gradeSheets = context.watch().state; - if (loading && gradeSheets.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 20), - itemBuilder: (context, index) => const SheetItemSkeleton(), - ); - } - - if ((error || gradeSheets.isEmpty) && !loading) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - error - ? 'Failed to load grade sheets. Please try again.' - : 'No grade sheets available.', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: gradeSheets.length, - separatorBuilder: (_, _) => const SizedBox(height: 20), - itemBuilder: (context, index) { - var sheet = gradeSheets[index]; - return SheetItemWidget( - text: sheet.name, - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GradeSheetRecordScreen( - gradeSheet: sheet, - classId: widget.classId, - onUpdatedVisibility: (newVisibility) { - context.read().updateGradeSheetVisibility( - sheet.id, - newVisibility, - ); - }, - ), - ), - ); - }, - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart deleted file mode 100644 index 87ca150e..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/sheet_item_widget.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -class SheetItemWidget extends StatelessWidget { - const SheetItemWidget({super.key, required this.text, required this.onTap}); - - final String text; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - InkWell( - onTap: onTap, - child: Container( - width: 385, - height: 50, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.all(12), - child: Text( - text, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart deleted file mode 100644 index ec485eeb..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; -import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddStudentGradeScreen extends StatefulWidget { - final String gradeSheetId; - final String studentId; - final String studentName; - final String classId; - - const AddStudentGradeScreen({ - super.key, - required this.gradeSheetId, - required this.studentId, - required this.studentName, - this.classId = 'single', - }); - - @override - State createState() => _AddStudentGradeScreenState(); -} - -class _AddStudentGradeScreenState extends State { - final formKey = GlobalKey(); - final TextEditingController gradeController = TextEditingController(); - - bool isSubmitting = false; - - bool get isFormValid => gradeController.text.trim().isNotEmpty; - - @override - void initState() { - super.initState(); - gradeController.addListener(() => setState(() {})); - } - - @override - void dispose() { - gradeController.dispose(); - super.dispose(); - } - - Future saveGrade() async { - if (!formKey.currentState!.validate()) return; - - setState(() { - isSubmitting = true; - }); - - try { - var record = await GetIt.I().addStudentRecord( - widget.studentId, - widget.gradeSheetId, - gradeController.text.trim(), - widget.classId, - ); - await GetIt.I().trackEvent( - 'add_student_record', - parameters: {'status': 'success'}, - ); - if (!mounted) return; - context.read().addStudentRecord(record); - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Added student grade successfully", - onComplete: (_) { - Navigator.pop(context); - Navigator.pop(context); - }, - ); - } on NotGradeSheetFound { - await GetIt.I().trackEvent( - 'add_student_record', - parameters: {'status': 'failed', 'error': 'grade_sheet_not_found'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Grade Sheet not found", - onComplete: (_) {}, - ); - } catch (_) { - await GetIt.I().trackEvent( - 'add_student_record', - parameters: {'status': 'failed', 'error': 'unknown_error'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - widget.studentName, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 30), - ThemedInputField( - controller: gradeController, - labelText: "Grade", - textInputAction: TextInputAction.done, - hintText: "A+", - validator: (v) => - v == null || v.isEmpty ? "Enter a grade" : null, - ), - const SizedBox(height: 30), - ], - ), - ), - ), - - PrimaryButton( - text: isSubmitting ? "Saving..." : "Save Student’s Grade ", - enabled: !isSubmitting && isFormValid, - onPressed: saveGrade, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart deleted file mode 100644 index 9bacdccc..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart +++ /dev/null @@ -1,432 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:csv/csv.dart'; -import 'package:excel/excel.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; -import 'package:yaspo_mobile/model/grade_sheets/grade_sheet_record.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; -import 'package:path/path.dart' as p; - -class BulkImportGradesScreen extends StatefulWidget { - final String gradeSheetId; - final String classId; - - const BulkImportGradesScreen({ - super.key, - required this.gradeSheetId, - this.classId = 'single', - }); - - @override - State createState() => _BulkImportGradesScreenState(); -} - -class _BulkImportGradesScreenState extends State { - List> fileData = []; - String? filePath; - List headers = []; - - String? selectedIdHeader; - String? selectedGradeHeader; - bool _loading = false; - - Future _pickCsvFile() async { - FilePickerResult? result = await FilePicker.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ['csv', 'xls', 'xlsx'], - ); - - List> fields = []; - - if (result != null) { - filePath = result.files.single.path; - String extension = p.extension(filePath!); - File file = File(filePath!); - - if (!mounted) return; - - if (file.lengthSync() > 0) { - if (extension == '.csv') { - Stream> input = File(filePath!).openRead(); - var csvString = await input.transform(utf8.decoder).join(); - fields = csv.decode(csvString); - } else if (extension == '.xls' || extension == '.xlsx') { - var bytes = file.readAsBytesSync(); - var excel = Excel.decodeBytes(bytes); - - for (var table in excel.tables.keys) { - for (var row in excel.tables[table]!.rows) { - List filteredRow = row - .where((cell) => cell != null) - .map((cell) => cell!.value.toString()) - .toList(); - if (filteredRow.isNotEmpty) { - fields.add(filteredRow); - } - } - break; - } - } - } - } - - if (fields.isEmpty || fields.length == 1) { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'The selected file has no data.', - onComplete: (_) {}, - ); - return; - } - - setState(() { - fileData = fields; - headers = fields.first.map((e) => e.toString()).toList(); - }); - } - - Future bulkImportRecords() async { - if (selectedIdHeader == null || selectedGradeHeader == null) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Please select both Student ID and Grade columns.', - onComplete: (_) {}, - ); - return; - } - - setState(() { - _loading = true; - }); - - try { - int idIndex = headers.indexOf(selectedIdHeader!); - int gradeIndex = headers.indexOf(selectedGradeHeader!); - - var validStudentIds = context - .read() - .state - .map((s) => s.id) - .toSet(); - - var existingRecords = context - .read() - .state - .map((r) => r.studentId) - .toSet(); - - List> organizedRecordsList = []; - - for (int i = 1; i < fileData.length; i++) { - var row = fileData[i]; - - if (row.isEmpty || row.length <= idIndex || row.length <= gradeIndex) { - continue; - } - - String currentId = row[idIndex].toString().trim(); - - if (!validStudentIds.contains(currentId)) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'One or more student IDs do not exist in this class.', - onComplete: (_) {}, - ); - setState(() => _loading = false); - await GetIt.I().trackEvent( - 'bulk_import_student_record', - parameters: {'status': 'failed', 'error': 'id_doesn\'t_exist'}, - ); - return; - } - - if (existingRecords.contains(currentId)) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Import failed. One or more IDs already exists', - onComplete: (_) {}, - ); - setState(() => _loading = false); - await GetIt.I().trackEvent( - 'bulk_import_student_record', - parameters: {'status': 'failed', 'error': 'id_already_exists'}, - ); - return; - } - - Map records = { - 'student_id': currentId, - 'grade': row[gradeIndex].toString().trim(), - }; - - organizedRecordsList.add(records); - } - - List newGradesIds = await GetIt.I() - .bulkImportRecords( - widget.gradeSheetId, - organizedRecordsList, - widget.classId, - ); - - List newRecordsList = []; - for (int i = 0; i < newGradesIds.length; i++) { - newRecordsList.add( - GradeSheetRecord( - id: newGradesIds[i], - studentId: organizedRecordsList[i]['student_id'], - grade: organizedRecordsList[i]['grade'], - ), - ); - } - - if (!mounted) return; - context.read().bulkImportRecords(newRecordsList); - - showTimedModalNotice( - context: context, - text: 'Records were imported successfully.', - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - duration: const Duration(seconds: 2), - onComplete: (_) { - if (mounted) Navigator.pop(context); - }, - ); - - await GetIt.I().trackEvent( - 'bulk_import_student_record', - parameters: {'status': 'success'}, - ); - } on NotGradeSheetFound { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Import failed. Class is not found.', - onComplete: (_) {}, - ); - await GetIt.I().trackEvent( - 'bulk_import_student_record', - parameters: {'status': 'failed', 'error': 'class_not_found'}, - ); - } catch (e) { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Import failed. Please recheck your data and try again.', - onComplete: (_) {}, - ); - await GetIt.I().trackEvent( - 'bulk_import_student_record', - parameters: {'status': 'failed', 'error': 'unknown_error'}, - ); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'Import Grade Records', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - Expanded( - child: headers.isEmpty - ? buildUploadView() - : buildHeaderMappingView(), - ), - if (headers.isNotEmpty) - PrimaryButton( - text: _loading ? 'Importing...' : 'Import', - onPressed: _loading ? () {} : bulkImportRecords, - ), - ], - ), - ), - ), - ); - } - - Widget buildUploadView() { - return InkWell( - splashColor: Colors.transparent, - onTap: () async { - await _pickCsvFile(); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Bulk Import Grade Records', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - 'Upload a CSV or Excel file containing grade records', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), - ), - SizedBox(height: 10), - Icon( - Icons.upload_file_outlined, - size: 92, - color: Theme.of(context).colorScheme.onSurface, - ), - ], - ), - ); - } - - Widget buildHeaderMappingView() { - return SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 25), - Text( - 'Map Data Columns', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - buildDropdownMenu( - label: 'Student ID Column', - currentValue: selectedIdHeader, - onSelected: (value) { - setState(() { - selectedIdHeader = value; - }); - }, - ), - buildDropdownMenu( - label: 'Grade Column', - currentValue: selectedGradeHeader, - onSelected: (value) { - setState(() { - selectedGradeHeader = value; - }); - }, - ), - ], - ), - ); - } - - Widget buildDropdownMenu({ - required String label, - required String? currentValue, - required ValueChanged onSelected, - }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - label, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SizedBox(height: 8), - DropdownMenu( - initialSelection: currentValue, - onSelected: onSelected, - inputDecorationTheme: InputDecorationTheme( - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.outline, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.outline, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, - ), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - hintText: '-- Select Column --', - expandedInsets: EdgeInsets.zero, - dropdownMenuEntries: headers.map((header) { - return DropdownMenuEntry( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.onPrimary, - ), - value: header, - label: header, - ); - }).toList(), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_header.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_header.dart deleted file mode 100644 index 926ea7b9..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_header.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChooseStudentHeader extends StatelessWidget { - const ChooseStudentHeader({super.key}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 8), - Text( - 'Choose a student', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_screen.dart deleted file mode 100644 index db63e4e5..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_screen.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/add_student_grade_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_header.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ChooseStudentScreen extends StatefulWidget { - const ChooseStudentScreen({ - super.key, - required this.gradeSheetId, - this.classId = 'single', - }); - - final String gradeSheetId; - final String classId; - - @override - State createState() => _ChooseStudentScreenState(); -} - -class _ChooseStudentScreenState extends State { - final searchController = TextEditingController(); - String searchQuery = ''; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - ChooseStudentHeader(), - const SizedBox(height: 24), - SizedBox( - width: 385, - height: 50, - child: TextFormField( - controller: searchController, - onChanged: (v) => setState(() => searchQuery = v), - decoration: InputDecoration( - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - hintText: "Filter Students", - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - ), - cursorColor: Theme.of(context).colorScheme.outline, - ), - ), - const SizedBox(height: 23), - Flexible( - child: RefreshIndicator( - onRefresh: () async {}, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(context), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent(BuildContext context) { - var students = context.watch().state; - var records = context.watch().state; - var gradedIds = records.map((r) => r.studentId).toSet(); - var availableStudents = students - .where((s) => !gradedIds.contains(s.id)) - .toList(); - - var filteredStudents = availableStudents - .where((s) => s.name.toLowerCase().contains(searchQuery.toLowerCase())) - .toList(); - - if (students.isEmpty) { - return _buildEmptyState('There are no students in this class.', context); - } - - if (availableStudents.isEmpty) { - return _buildEmptyState('All students already have grades.', context); - } - - if (filteredStudents.isEmpty) { - return _buildEmptyState('No students found.', context); - } - - return Container( - width: 385, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: filteredStudents.length, - separatorBuilder: (_, _) => - Divider(color: Theme.of(context).colorScheme.outline, height: 2), - itemBuilder: (context, index) { - var student = filteredStudents[index]; - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => AddStudentGradeScreen( - gradeSheetId: widget.gradeSheetId, - studentId: student.id, - studentName: student.name, - classId: widget.classId, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 18), - child: Row( - children: [ - Image.asset( - "assets/images/profile_picture.png", - width: 48, - height: 48, - fit: BoxFit.contain, - ), - const SizedBox(width: 14), - Expanded( - child: Text( - student.name, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ); - }, - ), - ); - } - - Widget _buildEmptyState(String message, BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - message, - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/grade_sheets_records_screen.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/grade_sheets_records_screen.dart deleted file mode 100644 index 6acb4650..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/grade_sheets_records_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; -import 'package:yaspo_mobile/model/grade_sheets/grade_sheet.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/record_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/record_widget.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class GradeSheetRecordScreen extends StatefulWidget { - final GradeSheet gradeSheet; - final Function(bool newVisibility) onUpdatedVisibility; - final String classId; - - const GradeSheetRecordScreen({ - super.key, - required this.gradeSheet, - required this.onUpdatedVisibility, - this.classId = 'single', - }); - - @override - State createState() => _GradeSheetRecordScreenState(); -} - -class _GradeSheetRecordScreenState extends State { - bool loading = false; - bool error = false; - String searchQuery = ''; - String? errorMessage; - final searchController = TextEditingController(); - - @override - void initState() { - super.initState(); - fetchRecords(); - } - - Future fetchRecords() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - errorMessage = null; - }); - - try { - var records = await GetIt.I().getRecords( - widget.gradeSheet.id, - widget.classId, - ); - await GetIt.I().trackEvent( - 'fetch_records', - parameters: {'status': 'success'}, - ); - - if (mounted) { - context.read().setGradeSheetRecords(records); - } - } on NotGradeSheetFound { - await GetIt.I().trackEvent( - 'fetch_records', - parameters: {'status': 'failed', 'error': 'grade_sheet_not_found'}, - ); - setState(() { - error = true; - errorMessage = "This grade sheet does not exist."; - }); - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_records', - parameters: {'status': 'failed', 'error': 'unknown_error'}, - ); - setState(() { - error = true; - errorMessage = "Failed to load grade sheet records. Please try again."; - }); - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - RecordHeader( - gradeSheet: widget.gradeSheet, - onUpdatedVisibility: widget.onUpdatedVisibility, - classId: widget.classId, - ), - const SizedBox(height: 10), - SizedBox( - width: 385, - height: 50, - child: TextFormField( - controller: searchController, - onChanged: (v) => setState(() => searchQuery = v), - decoration: InputDecoration( - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - hintText: "Filter Students", - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - ), - cursorColor: Theme.of(context).colorScheme.outline, - ), - ), - - const SizedBox(height: 24), - Flexible( - child: RefreshIndicator( - onRefresh: fetchRecords, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent() { - var records = context.watch().state; - var filtered = records.where((r) { - var student = context.read().getStudentById(r.studentId); - if (student == null) return false; - return student.name.toLowerCase().contains(searchQuery.toLowerCase()); - }).toList(); - - if (loading && records.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if ((error || filtered.isEmpty) && !loading) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - errorMessage ?? "No students available.", - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } - - return Container( - width: 385, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const AlwaysScrollableScrollPhysics(), - itemCount: filtered.length, - separatorBuilder: (_, _) => - Divider(color: Theme.of(context).colorScheme.outline, height: 2), - itemBuilder: (context, index) { - var record = filtered[index]; - return RecordWidget( - sheetId: widget.gradeSheet.id, - record: record, - classId: widget.classId, - students: context.read().state, - onDelete: () { - context.read().deleteStudentRecord( - record.id, - ); - }, - onUpdate: (updatedRecord) { - context.read().updateStudentRecord( - updatedRecord, - ); - }, - ); - }, - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_header.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_header.dart deleted file mode 100644 index 692d95a4..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_header.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/download_csv_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_record_cubit.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/model/grade_sheets/grade_sheet.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/bulk_add_grade_records_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/student_grade/choose_student_screen.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/options/option_tile.dart'; -import 'package:yaspo_mobile/ui/common/options/options_item.dart'; - -class RecordHeader extends StatelessWidget { - final GradeSheet gradeSheet; - final Function(bool newVisibility) onUpdatedVisibility; - final String classId; - - const RecordHeader({ - super.key, - required this.gradeSheet, - required this.onUpdatedVisibility, - this.classId = 'single', - }); - - @override - Widget build(BuildContext context) { - var records = context.watch().state; - return Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Expanded( - child: Text( - gradeSheet.name, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const Spacer(), - IconButton( - icon: Icon( - Icons.upload_file_outlined, - size: 30, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => BulkImportGradesScreen( - gradeSheetId: gradeSheet.id, - classId: classId, - ), - ), - ); - }, - ), - IconButton( - icon: Icon( - Icons.add, - size: 30, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ChooseStudentScreen( - gradeSheetId: gradeSheet.id, - classId: classId, - ), - ), - ); - }, - ), - OptionsItem( - iconKey: Key("${gradeSheet.id}-options"), - options: [ - OptionTile( - optionKey: Key("${gradeSheet.id}-delete"), - label: 'Delete Grade Sheet', - icon: Icons.delete, - onTap: () async { - var result = await showCustomDialog( - context: context, - title: "Are you sure you want to delete this grade sheet?", - message: "This action cannot be undone.", - confirmText: "Delete", - ); - if (!context.mounted) return; - if (result == null || !result.$1) return; - - await GetIt.I().deleteGradeSheet( - gradeSheet.id, - classId, - ); - await GetIt.I().trackEvent( - 'delete_grade_sheet', - parameters: {'status': 'success'}, - ); - if (!context.mounted) return; - context.read().removeGradeSheet(gradeSheet.id); - showTimedModalNotice( - context: context, - duration: Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Delete grade sheet successfully", - onComplete: (_) { - Navigator.pop(context); - }, - ); - }, - ), - OptionTile( - optionKey: Key("${gradeSheet.id}-update"), - label: 'Update Visibility', - icon: Icons.edit_outlined, - onTap: () async { - var liveSheet = context - .read() - .state - .where((s) => s.id == gradeSheet.id) - .firstOrNull; - - if (liveSheet == null) return; - - var result = await showCustomDialog( - context: context, - title: "Change Visibility", - message: - "Choose whether this grade is visible to the student.", - confirmText: "Update", - hasCheckbox: true, - initialCheckboxValue: liveSheet.visible ?? false, - ); - - if (result == null) return; - var (confirmed, newVisibility) = result; - if (!confirmed) return; - - await GetIt.I().updateGradeSheetVisibility( - gradeSheet.id, - newVisibility, - classId, - ); - onUpdatedVisibility(newVisibility); - if (!context.mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Update grade sheet visibility successfully", - onComplete: (_) {}, - ); - }, - ), - OptionTile( - optionKey: Key("${gradeSheet.id}-download"), - label: 'Download as CSV', - icon: Icons.download, - onTap: () async { - if (records.isEmpty) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.warning_amber_rounded, - iconColor: Colors.yellow.shade700, - text: "No students record in this grade sheet", - onComplete: (_) {}, - ); - return; - } - - try { - var nameController = TextEditingController(); - var result = await showCustomDialog( - context: context, - title: "Download grades as CSV", - message: "Enter file name", - confirmText: "Download", - hasInput: true, - controller: nameController, - hintText: "Enter file name", - ); - - if (!context.mounted) return; - if (result == null) return; - var (confirmed, _) = result; - if (!confirmed) return; - - var fileName = nameController.text.trim(); - if (fileName.isEmpty) return; - - var rows = [ - ["Student Name", "Grade"], - ]; - - for (var r in records) { - var student = context.read().getStudentById( - r.studentId, - ); - rows.add([ - if (context.mounted) - (student?.name ?? "Unknown").toLowerCase(), - r.grade, - ]); - } - - var didSave = await GetIt.I().downloadCSV( - fileName, - rows, - ); - - await GetIt.I().trackEvent( - 'download_grade_sheet_csv', - parameters: {'status': 'success'}, - ); - if (!context.mounted) return; - - if (didSave) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Download grade sheet CSV successfully", - onComplete: (_) {}, - ); - } else { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Download canceled", - onComplete: (_) {}, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'download_grade_sheet_csv', - parameters: {'status': 'failed'}, - ); - if (!context.mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Download grade sheet CSV failed", - onComplete: (_) {}, - ); - } - }, - ), - ], - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_widget.dart b/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_widget.dart deleted file mode 100644 index afd35f6a..00000000 --- a/mobile/lib/ui/app/single-class/staff/grade_sheets/student_grade/record_widget.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/exceptions/grade_sheet_record_exception.dart'; -import 'package:yaspo_mobile/model/grade_sheets/grade_sheet_record.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/ui/common/options/option_tile.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class RecordWidget extends StatelessWidget { - const RecordWidget({ - super.key, - required this.sheetId, - required this.record, - required this.onDelete, - required this.students, - required this.onUpdate, - this.classId = 'single', - }); - - final String sheetId; - final GradeSheetRecord record; - final VoidCallback onDelete; - final void Function(GradeSheetRecord updatedRecord) onUpdate; - final List students; - final String classId; - - @override - Widget build(BuildContext context) { - var gradeController = TextEditingController(text: record.grade); - - return GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (modalContext) { - return Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - OptionTile( - optionKey: Key("${record.id}-update"), - label: 'Update Grade', - icon: Icons.edit_outlined, - onTap: () async { - Navigator.pop(modalContext); - gradeController.text = record.grade; - var result = await showCustomDialog( - context: context, - title: "Update grade", - message: "Enter the new grade for this student.", - confirmText: "Update", - hasInput: true, - controller: gradeController, - hintText: "Enter new grade", - ); - - if (result == null || !result.$1) return; - - var newGrade = gradeController.text.trim(); - if (newGrade.isEmpty && context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Grade cannot be empty", - onComplete: (_) {}, - ); - } - - try { - await GetIt.I().updateStudentRecord( - record.id, - sheetId, - newGrade, - classId, - ); - await GetIt.I().trackEvent( - 'update_student_grade', - parameters: {'status': 'success'}, - ); - onUpdate( - GradeSheetRecord( - id: record.id, - studentId: record.studentId, - grade: newGrade, - ), - ); - - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Grade update successfully", - onComplete: (_) {}, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'update_student_grade', - parameters: {'status': 'failed'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Failed to update grade", - onComplete: (_) {}, - ); - } - } - }, - ), - OptionTile( - optionKey: Key("${record.id}-delete"), - label: 'Delete Record', - icon: Icons.delete, - onTap: () async { - Navigator.pop(modalContext); - var result = await showCustomDialog( - context: context, - title: - "Are you sure you want to delete this student's grade?", - message: "This action cannot be undone.", - confirmText: "Delete", - ); - - if (result == null || !result.$1) return; - - try { - await GetIt.I().deleteStudentRecord( - sheetId, - record.id, - classId, - ); - await GetIt.I().trackEvent( - 'delete_student_grade', - parameters: {'status': 'success'}, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Record delete successfully", - onComplete: (_) {}, - ); - } - onDelete(); - } on NotGradeSheetFound { - await GetIt.I().trackEvent( - 'delete_student_grade', - parameters: { - 'status': 'failed', - 'error': 'grade_sheet_not_found', - }, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Grade Sheet not found", - onComplete: (_) {}, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'delete_student_grade', - parameters: { - 'status': 'failed', - 'error': 'unknown_error', - }, - ); - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Failed to delete record", - onComplete: (_) {}, - ); - } - } - }, - ), - ], - ), - ); - }, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 18), - child: Row( - children: [ - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 45, - height: 45, - fit: BoxFit.contain, - ), - ), - const SizedBox(width: 17), - Expanded( - child: Text( - context - .read() - .getStudentById(record.studentId)! - .name, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Text( - record.grade, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/meetings/meeting_room_screen.dart b/mobile/lib/ui/app/single-class/staff/meetings/meeting_room_screen.dart deleted file mode 100644 index 1f6ed0b0..00000000 --- a/mobile/lib/ui/app/single-class/staff/meetings/meeting_room_screen.dart +++ /dev/null @@ -1,775 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:livekit_client/livekit_client.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; -import 'package:yaspo_mobile/abstract/meeting_service.dart'; -import 'package:yaspo_mobile/cubit/meeting/meeting_cubit.dart'; -import 'package:yaspo_mobile/model/meeting/meeting.dart'; -import 'package:yaspo_mobile/model/meeting/meeting_chat_message.dart'; -import 'package:yaspo_mobile/platform/meeting_foreground_service.dart'; - -const _livekitUrl = 'wss://livekit.staging.yaspo.twoaxis.org'; - -class MeetingRoomScreen extends StatefulWidget { - const MeetingRoomScreen({ - super.key, - required this.token, - required this.meeting, - required this.isEducator, - required this.classId, - required this.onMeetingEnded, - }); - final String token; - final Meeting meeting; - final bool isEducator; - final String classId; - final void Function(Meeting) onMeetingEnded; - - @override - State createState() => _MeetingRoomScreenState(); -} - -class _MeetingRoomScreenState extends State { - Room? _room; - bool _connected = false; - String? _connectionError; - - bool _micOn = false; - bool _camOn = false; - - VideoTrack? _localVideoTrack; - final Map _remoteVideoTracks = {}; - final Map _remoteAudioTracks = {}; - final Map _remoteMicMuted = {}; - List _remoteParticipants = []; - - List _chatMessages = []; - final _chatController = TextEditingController(); - final _chatScrollController = ScrollController(); - bool _sendingChat = false; - - int _activeTab = 0; // 0=video, 1=participants, 2=chat - StreamSubscription? _serviceEventSub; - - final _meetingService = GetIt.I(); - - @override - void initState() { - super.initState(); - WakelockPlus.enable(); - _connect(); - _listenServiceEvents(); - } - - @override - void dispose() { - WakelockPlus.disable(); - _chatController.dispose(); - _chatScrollController.dispose(); - _serviceEventSub?.cancel(); - _room?.disconnect(); - MeetingForegroundServiceBridge.stop(); - super.dispose(); - } - - void _listenServiceEvents() { - _serviceEventSub = MeetingForegroundServiceBridge.events.listen((event) { - if (!mounted) return; - if (event == 'mute') _toggleMic(); - if (event == 'hangup') _handleLeave(); - }); - } - - Future _connect() async { - var room = Room( - roomOptions: const RoomOptions(adaptiveStream: true, dynacast: true), - ); - _room = room; - - var listener = room.createListener(); - listener - ..on((_) => _refreshParticipants()) - ..on((_) => _refreshParticipants()) - ..on((e) => _onTrackSubscribed(e)) - ..on((e) => _onTrackUnsubscribed(e)) - ..on((e) => _onTrackMuted(e, true)) - ..on((e) => _onTrackMuted(e, false)) - ..on((e) => _onLocalTrackPublished(e)) - ..on((e) => _onLocalTrackUnpublished(e)) - ..on((e) => _onDataReceived(e)) - ..on((_) => _handleLeave()); - - try { - await room.connect(_livekitUrl, widget.token); - if (!mounted) return; - setState(() => _connected = true); - _refreshParticipants(); - _loadChatHistory(); - MeetingForegroundServiceBridge.start( - meetingName: widget.meeting.name, micOn: false); - context.read().setMicOn(false); - context.read().setCamOn(false); - } catch (e) { - if (mounted) setState(() => _connectionError = 'Failed to connect. Please try again.'); - } - } - - void _refreshParticipants() { - if (!mounted || _room == null) return; - setState(() { - _remoteParticipants = _room!.remoteParticipants.values.toList(); - }); - } - - void _onTrackSubscribed(TrackSubscribedEvent e) { - if (!mounted) return; - var identity = e.participant.identity; - setState(() { - if (e.track is VideoTrack) { - _remoteVideoTracks[identity] = e.track as VideoTrack; - } else if (e.track is AudioTrack) { - _remoteAudioTracks[identity] = e.track as AudioTrack; - } - }); - } - - void _onTrackUnsubscribed(TrackUnsubscribedEvent e) { - if (!mounted) return; - var identity = e.participant.identity; - setState(() { - if (e.track is VideoTrack) _remoteVideoTracks.remove(identity); - if (e.track is AudioTrack) _remoteAudioTracks.remove(identity); - }); - } - - void _onTrackMuted(dynamic e, bool muted) { - if (!mounted) return; - var pub = e.publication as TrackPublication?; - var participant = e.participant as Participant?; - if (pub?.kind == TrackType.AUDIO && participant != null) { - setState(() => _remoteMicMuted[participant.identity] = muted); - } - } - - void _onLocalTrackPublished(LocalTrackPublishedEvent e) { - if (!mounted) return; - if (e.publication.kind == TrackType.VIDEO) { - setState(() => _localVideoTrack = e.publication.track as VideoTrack?); - } - } - - void _onLocalTrackUnpublished(LocalTrackUnpublishedEvent e) { - if (!mounted) return; - if (e.publication.kind == TrackType.VIDEO) { - setState(() => _localVideoTrack = null); - } - } - - void _onDataReceived(DataReceivedEvent e) { - if (!mounted) return; - try { - var json = jsonDecode(utf8.decode(e.data)) as Map; - if (json['type'] == 'chat') { - var msg = MeetingChatMessage.fromJson(json); - setState(() => _chatMessages.add(msg)); - _scrollChatToBottom(); - } - } catch (err, stack) { - debugPrint('Error parsing received data: $err\n$stack'); - } - } - - Future _loadChatHistory() async { - try { - var msgs = await _meetingService.getChatMessages(widget.meeting.id, widget.classId); - if (mounted) { - setState(() => _chatMessages = msgs); - _scrollChatToBottom(); - } - } catch (err, stack) { - debugPrint('Error loading chat history: $err\n$stack'); - } - } - - void _scrollChatToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_chatScrollController.hasClients) { - _chatScrollController.animateTo( - _chatScrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } - - Future _toggleMic() async { - if (_room?.localParticipant == null) return; - var enabled = !_micOn; - try { - await _room!.localParticipant!.setMicrophoneEnabled(enabled); - if (!mounted) return; - setState(() => _micOn = enabled); - context.read().setMicOn(enabled); - await MeetingForegroundServiceBridge.updateMicState(enabled); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to access microphone. Please check permissions.')), - ); - } - } - } - - Future _toggleCam() async { - if (_room?.localParticipant == null) return; - var enabled = !_camOn; - try { - await _room!.localParticipant!.setCameraEnabled(enabled); - if (!mounted) return; - setState(() => _camOn = enabled); - context.read().setCamOn(enabled); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to access camera. Please check permissions.')), - ); - } - } - } - - bool _isLeaving = false; - - Future _handleLeave() async { - if (_isLeaving) return; - _isLeaving = true; - await _room?.disconnect(); - MeetingForegroundServiceBridge.stop(); - if (!mounted) return; - context.read().endCall(); - try { - await _meetingService.leaveMeeting(widget.meeting.id, widget.classId); - } catch (_) {} - if (mounted) Navigator.pop(context); - } - - Future _handleEnd() async { - if (_isLeaving) return; - _isLeaving = true; - await _room?.disconnect(); - MeetingForegroundServiceBridge.stop(); - if (!mounted) return; - context.read().endCall(); - try { - await _meetingService.endMeeting(widget.meeting.id, widget.classId); - } catch (_) {} - widget.onMeetingEnded(widget.meeting.copyWith(status: 'COMPLETED')); - if (mounted) Navigator.pop(context); - } - - Future _sendChat() async { - var text = _chatController.text.trim(); - if (text.isEmpty || _sendingChat || _room == null || _room!.localParticipant == null) return; - _chatController.clear(); - setState(() => _sendingChat = true); - try { - var msg = await _meetingService.sendChatMessage(widget.meeting.id, text, widget.classId); - var data = utf8.encode(jsonEncode({ - 'type': 'chat', - 'id': msg.id, - 'meetingId': msg.meetingId, - 'senderId': msg.senderId, - 'senderName': msg.senderName, - 'message': msg.message, - 'sentAt': msg.sentAt, - })); - await _room!.localParticipant!.publishData(data, reliable: true); - if (mounted) { - setState(() => _chatMessages.add(msg)); - _scrollChatToBottom(); - } - } catch (err, stack) { - debugPrint('Error sending chat message: $err\n$stack'); - } finally { - if (mounted) setState(() => _sendingChat = false); - } - } - - @override - Widget build(BuildContext context) { - var primary = Theme.of(context).colorScheme.primary; - - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: SafeArea( - child: Column( - children: [ - _buildTopBar(context), - if (!_connected && _connectionError == null) _buildConnecting(), - if (_connectionError != null) _buildError(), - if (_connected) - Expanded( - child: Column( - children: [ - // Tab bar - _buildTabBar(primary), - // Content - Expanded(child: _buildTabContent()), - // Controls - _buildControls(context), - ], - ), - ), - ], - ), - ), - ); - } - - Widget _buildTopBar(BuildContext context) { - var count = _remoteParticipants.length + 1; - return Container( - height: 60, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFA72222), - borderRadius: BorderRadius.circular(20), - ), - child: const Text('LIVE', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 1)), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - widget.meeting.name, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFFA72222)), - overflow: TextOverflow.ellipsis, - ), - ), - Icon(Icons.people_outline, size: 16, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), - const SizedBox(width: 4), - Text('$count', style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), - ], - ), - ); - } - - Widget _buildConnecting() => Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CircularProgressIndicator(color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 16), - Text('Connecting to room…', style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), - ], - ), - ), - ); - - Widget _buildError() => Expanded( - child: Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.videocam_off_outlined, size: 60, color: Color(0xFFA72222)), - const SizedBox(height: 16), - const Text('Connection Failed', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text(_connectionError ?? '', textAlign: TextAlign.center, style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))), - const SizedBox(height: 24), - ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFA72222), foregroundColor: Colors.white), - child: const Text('Go Back'), - ), - ], - ), - ), - ), - ); - - Widget _buildTabBar(Color primary) { - var tabs = [('Video', Icons.videocam_outlined), ('People', Icons.people_outline), ('Chat', Icons.chat_bubble_outline)]; - return Container( - height: 44, - color: Theme.of(context).colorScheme.surfaceContainer, - child: Row( - children: List.generate(tabs.length, (i) { - var active = _activeTab == i; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _activeTab = i), - child: Container( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: active ? primary : Colors.transparent, width: 2)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(tabs[i].$2, size: 14, color: active ? primary : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)), - const SizedBox(width: 4), - Text(tabs[i].$1, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: active ? primary : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4))), - ], - ), - ), - ), - ); - }), - ), - ); - } - - Widget _buildTabContent() { - return switch (_activeTab) { - 0 => _buildVideoGrid(), - 1 => _buildParticipantsList(), - 2 => _buildChat(), - _ => const SizedBox(), - }; - } - - Widget _buildVideoGrid() { - var tiles = [ - _VideoTile( - label: '${_room?.localParticipant?.name ?? 'You'} (You)', - videoTrack: _camOn ? _localVideoTrack : null, - isMuted: !_micOn, - isLocal: true, - ), - ..._remoteParticipants.map((p) => _VideoTile( - label: p.name.isNotEmpty ? p.name : p.identity, - videoTrack: _remoteVideoTracks[p.identity], - isMuted: _remoteMicMuted[p.identity] ?? false, - )), - ]; - - // Render remote audio tracks - var audioWidgets = _remoteAudioTracks.values.map((t) => _AudioTrackWidget(track: t)).toList(); - - return Stack( - children: [ - GridView.builder( - padding: const EdgeInsets.all(12), - itemCount: tiles.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: tiles.length == 1 ? 1 : 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: 9 / 16, - ), - itemBuilder: (_, i) => tiles[i], - ), - ...audioWidgets, - ], - ); - } - - Widget _buildParticipantsList() { - var all = [ - {'name': _room?.localParticipant?.name ?? 'You', 'isMe': true, 'isMuted': !_micOn}, - ..._remoteParticipants.map((p) => {'name': p.name.isNotEmpty ? p.name : p.identity, 'isMe': false, 'isMuted': _remoteMicMuted[p.identity] ?? false}), - ]; - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: all.length, - separatorBuilder: (_, index) => const Divider(height: 1), - itemBuilder: (_, i) { - var p = all[i]; - var muted = p['isMuted'] as bool; - return ListTile( - leading: CircleAvatar( - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - child: Icon(Icons.person, color: Theme.of(context).colorScheme.primary), - ), - title: Text('${p['name']}${p['isMe'] == true ? ' (You)' : ''}', - style: const TextStyle(fontWeight: FontWeight.w500)), - trailing: Icon( - muted ? Icons.mic_off : Icons.mic, - color: muted ? Colors.red : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), - size: 18, - ), - ); - }, - ); - } - - Widget _buildChat() { - var myId = _room?.localParticipant?.identity ?? ''; - return Column( - children: [ - Expanded( - child: ListView.builder( - controller: _chatScrollController, - padding: const EdgeInsets.all(12), - itemCount: _chatMessages.length, - itemBuilder: (_, i) { - var msg = _chatMessages[i]; - var isMe = msg.senderId == myId; - return Align( - alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 8), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.75), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: isMe ? const Color(0xFFA72222) : Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(14), - ), - child: Column( - crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Text(isMe ? 'You' : msg.senderName, - style: TextStyle(fontSize: 10, fontWeight: FontWeight.w700, - color: isMe ? Colors.white70 : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), - const SizedBox(height: 2), - Text(msg.message, - style: TextStyle(fontSize: 14, color: isMe ? Colors.white : Theme.of(context).colorScheme.onSurface)), - ], - ), - ), - ); - }, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _chatController, - decoration: InputDecoration( - hintText: 'Type a message…', - filled: true, - fillColor: Theme.of(context).colorScheme.surface, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - ), - onSubmitted: (_) => _sendChat(), - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: _sendChat, - child: Container( - width: 40, height: 40, - decoration: const BoxDecoration(color: Color(0xFFA72222), shape: BoxShape.circle), - child: const Icon(Icons.send, color: Colors.white, size: 18), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildControls(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ControlButton( - icon: _micOn ? Icons.mic : Icons.mic_off, - label: _micOn ? 'Mute' : 'Unmute', - active: _micOn, - onTap: _toggleMic, - ), - _ControlButton( - icon: _camOn ? Icons.videocam : Icons.videocam_off, - label: _camOn ? 'Stop Cam' : 'Start Cam', - active: _camOn, - onTap: _toggleCam, - ), - _LeaveButton(label: 'Leave', onTap: _handleLeave), - if (widget.isEducator) - _EndButton(onTap: _handleEnd), - ], - ), - ); - } -} - -// ─── Video Tile ───────────────────────────────────────────────────────────── - -class _VideoTile extends StatelessWidget { - const _VideoTile({required this.label, this.videoTrack, this.isMuted = false, this.isLocal = false}); - final String label; - final VideoTrack? videoTrack; - final bool isMuted; - final bool isLocal; - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - border: Border.all( - color: isLocal ? const Color(0xFFA72222) : Theme.of(context).colorScheme.outlineVariant, - width: isLocal ? 2 : 1, - ), - borderRadius: BorderRadius.circular(14), - ), - child: Stack( - fit: StackFit.expand, - children: [ - if (videoTrack != null) - VideoTrackRenderer(videoTrack!) - else - Center( - child: Icon(Icons.person, size: 48, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), - ), - Positioned( - bottom: 8, left: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), - child: Text(label, style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600)), - ), - ), - if (isMuted) - Positioned( - top: 8, right: 8, - child: Container( - width: 24, height: 24, - decoration: const BoxDecoration(color: Color(0xFFDC2626), shape: BoxShape.circle), - child: const Icon(Icons.mic_off, color: Colors.white, size: 13), - ), - ), - ], - ), - ), - ); - } -} - -// ─── Audio Widget ──────────────────────────────────────────────────────────── - -class _AudioTrackWidget extends StatefulWidget { - const _AudioTrackWidget({required this.track}); - final AudioTrack track; - @override - State<_AudioTrackWidget> createState() => _AudioTrackWidgetState(); -} - -class _AudioTrackWidgetState extends State<_AudioTrackWidget> { - @override - Widget build(BuildContext context) => const SizedBox.shrink(); -} - -// ─── Control Buttons ───────────────────────────────────────────────────────── - -class _ControlButton extends StatelessWidget { - const _ControlButton({required this.icon, required this.label, required this.onTap, this.active = true}); - final IconData icon; - final String label; - final VoidCallback onTap; - final bool active; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: onTap, - child: Container( - width: 52, height: 52, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: active ? Theme.of(context).colorScheme.surface : const Color(0xFFF5E5E5), - border: Border.all(color: active ? Theme.of(context).colorScheme.outlineVariant : Colors.transparent), - ), - child: Icon(icon, color: active ? Theme.of(context).colorScheme.onSurface : const Color(0xFFDC2626), size: 22), - ), - ), - const SizedBox(height: 4), - Text(label, style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))), - ], - ); - } -} - -class _LeaveButton extends StatelessWidget { - const _LeaveButton({required this.label, required this.onTap}); - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: onTap, - child: Container( - width: 52, height: 52, - decoration: const BoxDecoration(color: Color(0xFFDC2626), shape: BoxShape.circle), - child: const Icon(Icons.call_end, color: Colors.white, size: 22), - ), - ), - const SizedBox(height: 4), - Text(label, style: TextStyle(fontSize: 10, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6))), - ], - ); - } -} - -class _EndButton extends StatelessWidget { - const _EndButton({required this.onTap}); - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: const Color(0xFFB91C1C), - borderRadius: BorderRadius.circular(26), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.call_end, color: Colors.white, size: 18), - SizedBox(width: 6), - Text('End All', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13)), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/meetings/meetings_screen.dart b/mobile/lib/ui/app/single-class/staff/meetings/meetings_screen.dart deleted file mode 100644 index 042297dd..00000000 --- a/mobile/lib/ui/app/single-class/staff/meetings/meetings_screen.dart +++ /dev/null @@ -1,578 +0,0 @@ -import 'dart:async'; -import 'package:permission_handler/permission_handler.dart' as ph; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/meeting_service.dart'; -import 'package:yaspo_mobile/cubit/meeting/meeting_cubit.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/meeting/meeting.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/meetings/meeting_room_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class MeetingsScreen extends StatefulWidget { - const MeetingsScreen({ - super.key, - this.classId = 'single', - }); - final String classId; - - @override - State createState() => _MeetingsScreenState(); -} - -class _MeetingsScreenState extends State { - List _meetings = []; - bool _loading = true; - bool _error = false; - bool _creating = false; - final _meetingService = GetIt.I(); - - @override - void initState() { - super.initState(); - _fetchMeetings(); - } - - Future _fetchMeetings() async { - if (!mounted) return; - setState(() { - _loading = true; - _error = false; - }); - try { - var data = await _meetingService.getMeetings(widget.classId); - if (mounted) setState(() => _meetings = data); - } catch (_) { - if (mounted) setState(() => _error = true); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - Future _requestPermissions() async { - var results = await [ - ph.Permission.camera, - ph.Permission.microphone, - ph.Permission.notification, - ].request(); - - var cameraGranted = results[ph.Permission.camera] == ph.PermissionStatus.granted; - var micGranted = results[ph.Permission.microphone] == ph.PermissionStatus.granted; - - return cameraGranted && micGranted; - } - - Future _startMeeting(bool isEducator) async { - var hasPerms = await _requestPermissions(); - if (!hasPerms) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Camera and microphone permissions are required.')), - ); - } - return; - } - - var controller = TextEditingController(); - if (!mounted) return; - var name = await showDialog( - context: context, - builder: (ctx) => _CreateMeetingDialog(controller: controller), - ); - if (name == null || name.trim().isEmpty) return; - - setState(() => _creating = true); - try { - var data = await _meetingService.startMeeting(name.trim(), widget.classId); - var meeting = data['meeting'] as Meeting; - var token = data['token'] as String; - if (!mounted) return; - setState(() { - _meetings = [meeting, ..._meetings]; - }); - _enterRoom(token: token, meeting: meeting, isEducator: isEducator); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to start meeting.')), - ); - } - } finally { - if (mounted) setState(() => _creating = false); - } - } - - Future _joinMeeting(Meeting meeting, bool isEducator) async { - var hasPerms = await _requestPermissions(); - if (!hasPerms) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Camera and microphone permissions are required.')), - ); - } - return; - } - - try { - var token = await _meetingService.joinMeeting(meeting.id, widget.classId); - if (!mounted) return; - _enterRoom(token: token, meeting: meeting, isEducator: isEducator); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('This meeting is no longer active.')), - ); - _fetchMeetings(); - } - } - } - - Future _enterRoom({ - required String token, - required Meeting meeting, - required bool isEducator, - }) async { - context.read().startOrJoin( - token: token, - meeting: meeting, - isEducator: isEducator, - classId: widget.classId, - ); - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => MeetingRoomScreen( - token: token, - meeting: meeting, - isEducator: isEducator, - classId: widget.classId, - onMeetingEnded: (updatedMeeting) { - setState(() { - _meetings = _meetings.map((m) => - m.id == updatedMeeting.id ? updatedMeeting : m - ).toList(); - }); - }, - ), - fullscreenDialog: true, - ), - ); - _fetchMeetings(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: SafeArea( - child: BlocBuilder( - builder: (context, user) { - var isEducator = user?.permissions.contains(Permission.EDUCATOR) ?? false; - return Column( - children: [ - _buildHeader(context, isEducator), - Expanded( - child: RefreshIndicator( - onRefresh: _fetchMeetings, - color: Theme.of(context).colorScheme.primary, - child: _buildBody(context, isEducator), - ), - ), - ], - ); - }, - ), - ), - ); - } - - Widget _buildHeader(BuildContext context, bool isEducator) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 8, 16, 0), - child: Row( - children: [ - IconButton( - icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - const SizedBox(width: 4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Meetings', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'Join a live session or view past meetings', - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - IconButton( - icon: Icon(Icons.refresh, color: Theme.of(context).colorScheme.onSurface), - onPressed: _fetchMeetings, - ), - if (isEducator) - _creating - ? const SizedBox( - width: 40, - height: 40, - child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))), - ) - : IconButton( - icon: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 28), - onPressed: () => _startMeeting(isEducator), - ), - ], - ), - ); - } - - Widget _buildBody(BuildContext context, bool isEducator) { - if (_loading && _meetings.isEmpty) { - return _buildSkeleton(); - } - - if (_error && _meetings.isEmpty) { - return LayoutBuilder(builder: (ctx, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.videocam_off_outlined, size: 56, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3)), - const SizedBox(height: 16), - Text('Could not load meetings.', - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5))), - ], - ), - ), - ), - ); - }); - } - - var active = _meetings.where((m) => m.isActive).toList(); - var past = _meetings.where((m) => !m.isActive).toList(); - - if (_meetings.isEmpty) { - return LayoutBuilder(builder: (ctx, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.video_call_outlined, size: 56, - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.3)), - const SizedBox(height: 16), - Text( - isEducator ? 'No meetings yet.\nTap + to start one!' : 'No meetings yet.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), - ), - ], - ), - ), - ), - ); - }); - } - - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing, vertical: 12), - children: [ - if (active.isNotEmpty) ...[ - _sectionLabel('Live Now'), - const SizedBox(height: 8), - ...active.map((m) => _ActiveMeetingBanner( - meeting: m, - onJoin: () => _joinMeeting(m, isEducator), - )), - const SizedBox(height: 24), - ], - if (past.isNotEmpty) ...[ - _sectionLabel('Past Meetings'), - const SizedBox(height: 8), - ...past.map((m) => _PastMeetingCard(meeting: m)), - ], - ], - ); - } - - Widget _sectionLabel(String text) => Text( - text.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.45), - ), - ); - - Widget _buildSkeleton() { - return ListView( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing, vertical: 12), - children: List.generate(4, (i) => _SkeletonCard()), - ); - } -} - -// ─── Active Meeting Banner ────────────────────────────────────────────────── - -class _ActiveMeetingBanner extends StatefulWidget { - const _ActiveMeetingBanner({required this.meeting, required this.onJoin}); - final Meeting meeting; - final VoidCallback onJoin; - - @override - State<_ActiveMeetingBanner> createState() => _ActiveMeetingBannerState(); -} - -class _ActiveMeetingBannerState extends State<_ActiveMeetingBanner> - with SingleTickerProviderStateMixin { - late AnimationController _dot; - late Animation _dotAnim; - - @override - void initState() { - super.initState(); - _dot = AnimationController(vsync: this, duration: const Duration(milliseconds: 1800)) - ..repeat(reverse: true); - _dotAnim = Tween(begin: 1.0, end: 0.4).animate(_dot); - } - - @override - void dispose() { - _dot.dispose(); - super.dispose(); - } - - String _formatTime(String dateStr) { - var d = DateTime.tryParse(dateStr); - if (d == null) return ''; - return '${d.month}/${d.day} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; - } - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFFF8F0), - border: Border.all(color: const Color(0xFFFFD0A0)), - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - AnimatedBuilder( - animation: _dotAnim, - builder: (_, child) => Opacity( - opacity: _dotAnim.value, - child: Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFFE05F00), - shape: BoxShape.circle, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.meeting.name, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 15, - color: Color(0xFFC84B00), - ), - ), - const SizedBox(height: 2), - Text( - 'Started ${_formatTime(widget.meeting.time)}', - style: const TextStyle(fontSize: 12, color: Color(0xFFA06030)), - ), - ], - ), - ), - BlocBuilder( - builder: (context, state) { - var isJoined = state.isActive && state.meeting?.id == widget.meeting.id; - return ElevatedButton.icon( - onPressed: isJoined ? null : widget.onJoin, - icon: const Icon(Icons.videocam, size: 16), - label: Text(isJoined ? 'Joined' : 'Join'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFA72222), - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.shade400, - disabledForegroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - textStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - ); - }, - ), - ], - ), - ); - } -} - -// ─── Past Meeting Card ────────────────────────────────────────────────────── - -class _PastMeetingCard extends StatelessWidget { - const _PastMeetingCard({required this.meeting}); - final Meeting meeting; - - String _formatTime(String dateStr) { - var d = DateTime.tryParse(dateStr); - if (d == null) return ''; - return '${d.month}/${d.day} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; - } - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.check_circle_outline, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - meeting.name, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - Text( - _formatTime(meeting.time), - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - 'Ended', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), - ), - ), - ], - ), - ); - } -} - -// ─── Skeleton Card ────────────────────────────────────────────────────────── - -class _SkeletonCard extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - height: 70, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - ); - } -} - -// ─── Create Meeting Dialog ────────────────────────────────────────────────── - -class _CreateMeetingDialog extends StatelessWidget { - const _CreateMeetingDialog({required this.controller}); - final TextEditingController controller; - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: const Text('Start a New Meeting'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - hintText: 'e.g. Chapter 5 Revision', - labelText: 'Meeting Name', - ), - onSubmitted: (_) => Navigator.pop(context, controller.text), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, controller.text), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFA72222), - foregroundColor: Colors.white, - ), - child: const Text('Start'), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/more/app_info_section.dart b/mobile/lib/ui/app/single-class/staff/more/app_info_section.dart deleted file mode 100644 index cef998c4..00000000 --- a/mobile/lib/ui/app/single-class/staff/more/app_info_section.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/values/version.dart'; - -class AppInfoSection extends StatelessWidget { - const AppInfoSection({super.key}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.build, - size: 20, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 8), - Text( - 'App Version', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - Text( - VERSION, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - Divider(color: Theme.of(context).colorScheme.outline, height: 2), - Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.build, - size: 20, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 8), - Text( - 'Build Number', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - Text( - BUILD_NUMBER.toString(), - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/more/more_options_list.dart b/mobile/lib/ui/app/single-class/staff/more/more_options_list.dart deleted file mode 100644 index 14e21c66..00000000 --- a/mobile/lib/ui/app/single-class/staff/more/more_options_list.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/class_settings/class_settings_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/grade_sheets/grade_sheets_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/meetings/meetings_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/more/row_item.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/quizzes_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff/staff_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/students/students_screen.dart'; - -class MoreOptionsList extends StatelessWidget { - const MoreOptionsList({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - List> optionsData = [ - { - "icon": Icons.groups, - "title": 'Students', - "permission": Permission.MANAGE_STUDENTS, - "page": StudentsScreen(), - }, - { - "icon": Icons.list, - "title": 'Quizzes', - "permission": Permission.EDUCATOR, - "page": QuizzesScreen(classId: classId), - }, - { - "icon": Icons.file_copy_outlined, - "title": 'Grade Sheets', - "permission": Permission.EDUCATOR, - "page": GradeSheets(classId: classId), - }, - { - "icon": Icons.shield, - "title": 'Staff', - "permission": Permission.MANAGE_STAFF, - "page": StaffScreen(), - }, - { - "icon": Icons.settings, - "title": "Class Settings", - "permission": Permission.MANAGE_TENANT, - "page": ClassSettingsScreen(classId: classId), - }, - { - "icon": Icons.video_call, - "title": "Meetings", - "permission": null, - "page": MeetingsScreen(classId: classId), - }, - ]; - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: BlocBuilder( - builder: (context, user) { - if (user == null) return const SizedBox.shrink(); - - var filteredOptions = optionsData.where((option) { - if (user.tenant?.type == TenantType.ORGANIZATION) { - if (option['title'] == 'Students' || option['title'] == 'Staff') { - return false; - } - } - Permission? permission = option['permission']; - if (permission == null) return true; - return user.permissions.contains(permission); - }).toList(); - - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: filteredOptions.length, - separatorBuilder: (context, index) => Divider( - color: Theme.of(context).colorScheme.outline, - height: 2, - ), - itemBuilder: (context, index) { - var option = filteredOptions[index]; - - return RowItem( - icon: option['icon'], - title: option['title'], - onTap: () { - var page = option['page']; - if (page == null) return; - Navigator.push( - context, - MaterialPageRoute(builder: (_) => page), - ); - }, - ); - }, - ); - }, - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/more/more_screen.dart b/mobile/lib/ui/app/single-class/staff/more/more_screen.dart deleted file mode 100644 index 1bf449dc..00000000 --- a/mobile/lib/ui/app/single-class/staff/more/more_screen.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/auth_service.dart'; -import 'package:yaspo_mobile/abstract/notification_service.dart'; -import 'package:yaspo_mobile/abstract/token_storage_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/more/app_info_section.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/more/more_options_list.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/profile_info_screen.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/ui/onboarding/onboarding/onboarding_screen.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class MoreScreen extends StatelessWidget { - const MoreScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - Future logout() async { - String? fcmToken = await GetIt.I().getFcmToken(); - - String? refreshToken = await GetIt.I() - .getRefreshToken(); - - await GetIt.I().logout(refreshToken!, fcmToken!); - await GetIt.I().clearTokens(); - await GetIt.I().trackEvent( - 'user_logout', - parameters: {'type': 'staff', 'status': 'success'}, - ); - await GetIt.I().clearUser(); - - if (context.mounted) { - context.read().setUserModel(null); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const OnboardingScreen()), - (route) => false, - ); - } - } - - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, user) { - if (user == null || - user.tenant == null || - user.tenant!.type == TenantType.ORGANIZATION) { - return const SizedBox.shrink(); - } - return Text( - user.tenant!.name, - style: TextStyle( - fontSize: 24, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ); - }, - ), - SizedBox(height: 24), - BlocBuilder( - builder: (context, user) { - return InkWell( - onTap: () { - if (user == null) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileInfoScreen( - staff: Staff( - id: user.id, - name: user.name, - email: user.email, - permissions: user.permissions, - createdAt: DateTime.now().toIso8601String(), - ), - onDelete: () {}, - onUpdate: () {}, - isFromMore: true, - ), - ), - ); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: 16, - horizontal: 20, - ), - child: Row( - children: [ - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 50, - height: 50, - fit: BoxFit.contain, - ), - ), - SizedBox(width: 15), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user?.name ?? 'You', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - const SizedBox(height: 1), - Text( - 'Account Settings', - style: TextStyle( - fontWeight: FontWeight.w400, - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios_outlined, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ], - ), - ), - ), - ); - }, - ), - const SizedBox(height: 25), - MoreOptionsList(classId: classId), - SizedBox(height: 15), - InkWell( - onTap: logout, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - Icon( - Icons.logout, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'Sign out', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 20), - const AppInfoSection(), - const SizedBox(height: 30), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/more/row_item.dart b/mobile/lib/ui/app/single-class/staff/more/row_item.dart deleted file mode 100644 index bf46a08a..00000000 --- a/mobile/lib/ui/app/single-class/staff/more/row_item.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; - -class RowItem extends StatelessWidget { - const RowItem({ - super.key, - required this.icon, - required this.title, - required this.onTap, - }); - - final IconData icon; - final String title; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - Icon(icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 10), - Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/edit_profile_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/edit_profile_screen.dart deleted file mode 100644 index 250509ed..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/edit_profile_screen.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/user_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/util/validators.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class EditProfileScreen extends StatefulWidget { - final User user; - - const EditProfileScreen({super.key, required this.user}); - - @override - State createState() => _EditProfileScreenState(); -} - -class _EditProfileScreenState extends State { - final GlobalKey formKey = GlobalKey(); - late final TextEditingController nameController; - late final TextEditingController emailController; - - bool isSubmitting = false; - bool nameSubmitted = false; - - @override - void initState() { - super.initState(); - nameController = TextEditingController(text: widget.user.name); - // Email is read-only - emailController = TextEditingController(text: widget.user.email); - - nameController.addListener(() => setState(() {})); - } - - bool get isFormValid { - if (nameController.text.trim().isEmpty) return false; - return true; - } - - bool get hasDataChanged { - return nameController.text.trim() != widget.user.name; - } - - Future updateProfile() async { - setState(() { - nameSubmitted = true; - }); - - if (!formKey.currentState!.validate()) return; - - setState(() => isSubmitting = true); - - try { - await GetIt.I().updateSelfProfile( - nameController.text.trim(), - ); - - if (!mounted) return; - - var updatedUser = User( - tenant: widget.user.tenant, - name: nameController.text.trim(), - email: widget.user.email, - type: widget.user.type, - id: widget.user.id, - permissions: widget.user.permissions, - ); - - context.read().setUserModel(updatedUser); - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Profile updated successfully", - onComplete: (_) => Navigator.pop(context), - ); - - GetIt.I().trackEvent( - 'update_profile', - parameters: {'type': widget.user.type, 'status': 'success'}, - ); - } catch (_) { - GetIt.I().trackEvent( - 'update_profile', - parameters: {'type': widget.user.type, 'status': 'failed'}, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void dispose() { - nameController.dispose(); - emailController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 10), - Text( - "Edit Profile", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 30), - Expanded( - child: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedInputField( - controller: nameController, - labelText: "Name", - hintText: "Your Name", - textInputAction: TextInputAction.next, - validator: (input) => - validateName(input, isSubmitted: nameSubmitted), - ), - const SizedBox(height: 31), - // Email is disabled/read-only as API does not support updating it via /users/me - ThemedInputField( - controller: emailController, - labelText: "Email", - hintText: "your.email@example.com", - readonly: true, - textInputAction: TextInputAction.done, - ), - const SizedBox(height: 8), - Text( - "Email cannot be changed.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ), - PrimaryButton( - text: isSubmitting ? "Saving..." : "Save Changes", - onPressed: isFormValid && hasDataChanged && !isSubmitting - ? updateProfile - : null, - enabled: isFormValid && hasDataChanged && !isSubmitting, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/profile_info_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/profile_info_screen.dart deleted file mode 100644 index c8d23900..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/profile_info_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/profile_info_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/profile_management_actions.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ProfileInfoScreen extends StatefulWidget { - final String? studentId; - final Student? student; - final Staff? staff; - final VoidCallback onDelete; - final Function? onUpdate; - final bool isFromMore; - final String classId; - - const ProfileInfoScreen({ - super.key, - this.studentId, - this.student, - this.staff, - required this.onDelete, - this.onUpdate, - this.isFromMore = false, - this.classId = 'single', - }) : assert(studentId != null || student != null || staff != null); - - @override - State createState() => _ProfileInfoScreenState(); -} - -class _ProfileInfoScreenState extends State { - Student? _student; - Staff? _staff; - bool isStudent = false; - bool loading = true; - - @override - void initState() { - super.initState(); - isStudent = - widget.studentId != null || - widget.student != null || - widget.staff == null; - if (widget.staff != null) { - _staff = widget.staff; - loading = false; - } else if (widget.student != null) { - _student = widget.student; - loading = false; - } else { - fetchStudent(); - } - } - - Future fetchStudent() async { - try { - var student = context.read().getStudentById( - widget.studentId!, - ); - if (student == null) { - throw Exception("Student not found"); - } - if (!mounted) return; - setState(() { - _student = student; - loading = false; - }); - } catch (_) { - if (!mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) => Navigator.pop(context), - ); - }); - } - } - - @override - Widget build(BuildContext context) { - if (loading) { - return Scaffold( - body: ProfileSkeleton(), - ); - } - - return _buildProfileView(context); - } - - Widget _buildProfileView(BuildContext context) { - String id = isStudent ? _student!.id : _staff!.id; - String name = isStudent ? _student!.name : _staff!.name; - String email = isStudent ? _student!.email : _staff!.email; - String createdAt = isStudent ? _student!.createdAt : _staff!.createdAt; - Student? student = isStudent ? _student : null; - Staff? staff = !isStudent ? _staff : null; - - bool isMe = !isStudent && context.read().state?.id == id; - - return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ProfileInfoHeader( - id: id, - name: name, - email: email, - createdAt: createdAt, - isStudent: isStudent, - isMe: isMe, - isFromMore: widget.isFromMore, - onDelete: widget.onDelete, - onStateUpdate: (updated) { - setState(() { - if (updated is Staff) { - _staff = updated; - } else if (updated is Student) { - _student = updated; - } - }); - }, - ), - if (!isStudent && staff != null) ...[ - const SizedBox(height: 24), - Align( - alignment: Alignment.centerLeft, - child: Text( - "Permissions", - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: Wrap( - spacing: 8.0, - runSpacing: 4.0, - children: staff.permissions - .map( - (p) => Chip( - label: Text( - p.name.replaceAll('_', ' '), - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - backgroundColor: Theme.of( - context, - ).colorScheme.outlineVariant, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - ), - ) - .toList(), - ), - ), - ], - if (isMe && !widget.isFromMore) ...[ - const SizedBox(height: 18), - Text( - "You cannot manage your own profile from here.", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ], - ProfileManagementActions( - isMe: isMe, - isFromMore: widget.isFromMore, - isStudent: isStudent, - student: student, - staff: staff, - classId: widget.classId, - onUpdate: widget.onUpdate, - onStateUpdate: (updated) { - setState(() { - if (updated is Staff) { - _staff = updated; - } else if (updated is Student) { - _student = updated; - } - }); - }, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart deleted file mode 100644 index b8f81669..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/reset_password_screen.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/staff_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ResetPasswordScreen extends StatefulWidget { - final Student? student; - final Staff? staff; - final String classId; - - const ResetPasswordScreen({ - super.key, - this.student, - this.staff, - this.classId = 'single', - }) : assert(student != null || staff != null); - - @override - State createState() => _ResetPasswordScreenUIState(); -} - -class _ResetPasswordScreenUIState extends State { - final passwordController = TextEditingController(); - - bool autoGeneratePassword = false; - bool sendInstructions = false; - bool isSubmitting = false; - - bool get canSubmit { - if (isSubmitting) return false; - if (autoGeneratePassword) return true; - return passwordController.text.trim().isNotEmpty; - } - - Future resetPassword() async { - if (!canSubmit) return; - - setState(() => isSubmitting = true); - - try { - if (widget.student != null) { - await GetIt.I().resetStudentPassword( - widget.student!.id, - autoGeneratePassword, - autoGeneratePassword ? null : passwordController.text.trim(), - autoGeneratePassword ? null : sendInstructions, - ); - GetIt.I().trackEvent( - 'reset_password', - parameters: {'type': 'student', 'status': 'success'}, - ); - } else { - await GetIt.I().resetStaffPassword( - widget.staff!.id, - autoGeneratePassword, - autoGeneratePassword ? null : passwordController.text.trim(), - autoGeneratePassword ? null : sendInstructions, - ); - GetIt.I().trackEvent( - 'reset_password', - parameters: {'type': 'staff', 'status': 'success'}, - ); - } - - if (!mounted) return; - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Password reset successfully", - onComplete: (_) => Navigator.pop(context), - ); - } catch (_) { - if (widget.student != null) { - GetIt.I().trackEvent( - 'reset_password', - parameters: {'type': 'student', 'status': 'failed'}, - ); - } else { - GetIt.I().trackEvent( - 'reset_password', - parameters: {'type': 'staff', 'status': 'failed'}, - ); - } - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void initState() { - super.initState(); - passwordController.addListener(() => setState(() {})); - } - - @override - void dispose() { - passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - widget.student?.name ?? widget.staff!.name, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 30), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Autogenerate a password", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: autoGeneratePassword, - onChanged: (value) { - setState(() { - autoGeneratePassword = value!; - if (autoGeneratePassword) sendInstructions = true; - }); - }, - ), - if (!autoGeneratePassword) ...[ - const SizedBox(height: 18.5), - ThemedInputField( - controller: passwordController, - labelText: "Password", - textInputAction: TextInputAction.done, - hintText: "•••••••••••••••••", - ), - const SizedBox(height: 20), - ], - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Send user instructions on how to login.", - style: TextStyle( - color: autoGeneratePassword - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: sendInstructions, - onChanged: autoGeneratePassword - ? null - : (v) => setState(() => sendInstructions = v!), - ), - ], - ), - ), - PrimaryButton( - text: isSubmitting ? "Resetting..." : "Reset Password", - onPressed: canSubmit ? resetPassword : null, - enabled: canSubmit, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart b/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart deleted file mode 100644 index 384cdff1..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/update_info_screen.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/staff_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/enums/user_type.dart'; -import 'package:yaspo_mobile/exceptions/student_exception.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/staff_permissions_section.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_form.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/widgets/update_info_header.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class UpdateInfoScreen extends StatefulWidget { - final Student? student; - final Staff? staff; - final String classId; - - const UpdateInfoScreen({ - super.key, - this.student, - this.staff, - this.classId = 'single', - }) : assert(student != null || staff != null); - - @override - State createState() => _UpdateInfoScreenState(); -} - -class _UpdateInfoScreenState extends State { - final GlobalKey formKey = GlobalKey(); - late final TextEditingController nameController; - late final TextEditingController emailController; - late Set selectedPermissions; - - late final bool isStudent; - - bool isSubmitting = false; - bool emailSubmitted = false; - bool nameSubmitted = false; - - bool get isFormValid { - if (nameController.text.trim().isEmpty) return false; - if (emailController.text.trim().isEmpty) return false; - if (!isStudent && selectedPermissions.isEmpty) return false; - return true; - } - - bool get hasDataChanged { - bool basicInfoChanged; - if (isStudent) { - basicInfoChanged = - nameController.text.trim() != widget.student!.name || - emailController.text.trim() != widget.student!.email; - } else { - var staff = widget.staff!; - basicInfoChanged = - nameController.text.trim() != staff.name || - emailController.text.trim() != staff.email || - !setEquals(selectedPermissions, staff.permissions.toSet()); - } - - return basicInfoChanged; - } - - @override - void initState() { - super.initState(); - - isStudent = widget.student != null; - - nameController = TextEditingController( - text: isStudent ? widget.student!.name : widget.staff!.name, - ); - emailController = TextEditingController( - text: isStudent ? widget.student!.email : widget.staff!.email, - ); - - if (!isStudent) { - selectedPermissions = widget.staff!.permissions.toSet(); - } - - nameController.addListener(() => setState(() {})); - emailController.addListener(() { - if (emailSubmitted) { - emailSubmitted = false; - formKey.currentState?.validate(); - } - setState(() {}); - }); - } - - Future updateInfo() async { - setState(() { - emailSubmitted = true; - nameSubmitted = true; - }); - - if (!formKey.currentState!.validate()) return; - - if (!isStudent && selectedPermissions.isEmpty) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Staff must have at least one permission", - onComplete: (_) {}, - ); - return; - } - - setState(() => isSubmitting = true); - - try { - dynamic result; - if (isStudent) { - await GetIt.I().updateStudentInfo( - widget.student!.id, - nameController.text.trim(), - emailController.text.trim(), - ); - await GetIt.I().trackEvent( - 'update_student_info', - parameters: {'status': 'success'}, - ); - - if (!mounted) return; - - var updatedStudent = Student( - id: widget.student!.id, - name: nameController.text.trim(), - email: emailController.text.trim(), - createdAt: widget.student!.createdAt, - ); - - context.read().updateStudent(updatedStudent); - - result = updatedStudent; - } else { - var staff = widget.staff!; - await GetIt.I().updateStaff( - staff.id, - name: nameController.text.trim() != staff.name - ? nameController.text.trim() - : null, - email: emailController.text.trim() != staff.email - ? emailController.text.trim() - : null, - permissions: - !setEquals(selectedPermissions, staff.permissions.toSet()) - ? selectedPermissions.toList() - : null, - ); - await GetIt.I().trackEvent( - 'update_staff_info', - parameters: { - 'status': 'success', - 'is_educator': selectedPermissions - .contains(Permission.EDUCATOR) - .toString(), - }, - ); - result = Staff( - id: staff.id, - name: nameController.text.trim(), - email: emailController.text.trim(), - permissions: selectedPermissions.toList(), - createdAt: staff.createdAt, - ); - - if (mounted && staff.id == context.read().state?.id) { - context.read().setUserModel( - User( - tenant: context.read().state?.tenant, - name: nameController.text.trim(), - email: emailController.text.trim(), - type: UserType.STAFF, - id: staff.id, - permissions: selectedPermissions.toList(), - ), - ); - } - } - - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "${isStudent ? "Student" : "Staff"} updated successfully", - onComplete: (_) => Navigator.pop(context, result), - ); - } on EmailAlreadyExist { - await GetIt.I().trackEvent( - 'update_info', - parameters: { - 'type': isStudent ? 'student' : 'staff', - 'status': 'failed', - 'error': 'already_exists', - }, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "E-mail is already used", - onComplete: (_) {}, - ); - } catch (_) { - await GetIt.I().trackEvent( - 'update_info', - parameters: { - 'type': isStudent ? 'student' : 'staff', - 'status': 'failed', - 'error': 'unknown_error', - }, - ); - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void dispose() { - nameController.dispose(); - emailController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - String headerName = isStudent ? widget.student!.name : widget.staff!.name; - - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - UpdateInfoHeader(name: headerName), - const SizedBox(height: 30), - Expanded( - child: Form( - key: formKey, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UpdateInfoForm( - nameController: nameController, - emailController: emailController, - nameSubmitted: nameSubmitted, - emailSubmitted: emailSubmitted, - ), - if (!isStudent) - StaffPermissionsSection( - selectedPermissions: selectedPermissions, - onPermissionChanged: (permission, isSelected) { - setState(() { - if (isSelected) { - selectedPermissions.add(permission); - } else { - selectedPermissions.remove(permission); - } - }); - }, - ), - ], - ), - ), - ), - ), - PrimaryButton( - text: isSubmitting ? "Updating..." : "Update Info", - onPressed: isFormValid && hasDataChanged && !isSubmitting - ? updateInfo - : null, - enabled: isFormValid && hasDataChanged && !isSubmitting, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_info_header.dart b/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_info_header.dart deleted file mode 100644 index 73d4e068..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_info_header.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/staff_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/edit_profile_screen.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; - -class ProfileInfoHeader extends StatelessWidget { - final String id; - final String name; - final String email; - final String createdAt; - final bool isStudent; - final bool isMe; - final bool isFromMore; - final VoidCallback onDelete; - final Function(dynamic) onStateUpdate; - - const ProfileInfoHeader({ - super.key, - required this.id, - required this.name, - required this.email, - required this.createdAt, - required this.isStudent, - required this.isMe, - required this.isFromMore, - required this.onDelete, - required this.onStateUpdate, - }); - - String _formatDate(String date) { - if (date.isEmpty) return 'N/A'; - try { - return DateFormat('dd/MM/yyyy').format(DateTime.parse(date)); - } catch (_) { - return 'N/A'; - } - } - - @override - Widget build(BuildContext context) { - var userState = context.watch().state; - var canEditSelf = - userState?.permissions.contains(Permission.MANAGE_SELF_PROFILE) ?? - false; - - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - if (isMe && isFromMore && canEditSelf) - IconButton( - icon: Icon( - Icons.edit_outlined, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EditProfileScreen(user: userState!), - ), - ); - if (!context.mounted) return; - var updatedMe = context.read().state; - if (updatedMe != null && updatedMe.id == id) { - onStateUpdate( - Staff( - id: updatedMe.id, - name: updatedMe.name, - email: updatedMe.email, - permissions: updatedMe.permissions, - createdAt: createdAt, - ), - ); - } - }, - ), - if (!isFromMore) ...[ - if (!isMe) - IconButton( - icon: Icon( - Icons.delete_outline_outlined, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - var confirm = await showCustomDialog( - context: context, - title: "Delete ${isStudent ? "student" : "staff"}?", - message: - "Are you sure you want to delete $name? This action cannot be undone.", - confirmText: "Delete", - ); - if (confirm == null || !confirm.$1) return; - - try { - if (isStudent) { - await GetIt.I().deleteStudent(id); - await GetIt.I().trackEvent( - 'remove_user', - parameters: {'type': 'student', 'status': 'success'}, - ); - } else { - await GetIt.I().deleteStaff(id); - await GetIt.I().trackEvent( - 'remove_user', - parameters: {'type': 'staff', 'status': 'success'}, - ); - } - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: - "${isStudent ? "Student" : "Staff"} deleted successfully", - onComplete: (_) { - onDelete(); - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - ); - } - } catch (_) { - if (context.mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } - } - }, - ) - else - IconButton( - onPressed: null, - icon: Icon( - Icons.delete_outline_outlined, - size: 25, - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ], - ), - const SizedBox(height: 30), - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 100, - height: 100, - fit: BoxFit.contain, - ), - ), - const SizedBox(height: 12), - Text( - name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Text( - email, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 6), - Text( - "Added ${_formatDate(createdAt)}", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_management_actions.dart b/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_management_actions.dart deleted file mode 100644 index 5194218d..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/widgets/profile_management_actions.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/reset_password_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/update_info_screen.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; - -class ProfileManagementActions extends StatelessWidget { - final bool isMe; - final bool isFromMore; - final bool isStudent; - final Student? student; - final Staff? staff; - final Function? onUpdate; - final Function(dynamic) onStateUpdate; - final String classId; - - const ProfileManagementActions({ - super.key, - required this.isMe, - required this.isFromMore, - required this.isStudent, - this.student, - this.staff, - this.onUpdate, - required this.onStateUpdate, - this.classId = 'single', - }); - - @override - Widget build(BuildContext context) { - if (isFromMore) { - return const SizedBox.shrink(); - } - - return Column( - children: [ - const SizedBox(height: 12), - if (!isMe) ...[ - PrimaryButton( - text: "Update Info", - onPressed: () async { - var result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => UpdateInfoScreen( - student: student, - staff: staff, - classId: classId, - ), - ), - ); - if (result != null) { - onStateUpdate(result); - if (onUpdate != null) { - onUpdate!(result); - } - } - }, - ), - const SizedBox(height: 15), - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ResetPasswordScreen( - student: student, - staff: staff, - classId: classId, - ), - ), - ); - }, - style: - ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 2, - ), - ), - minimumSize: const Size(385, 50), - ).copyWith( - overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) { - return Theme.of(context).colorScheme.surface.withAlpha(0); - } - return null; - }), - ), - child: Text( - "Reset Password", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ), - ), - const SizedBox(height: 15), - ], - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/widgets/staff_permissions_section.dart b/mobile/lib/ui/app/single-class/staff/profile/widgets/staff_permissions_section.dart deleted file mode 100644 index e8c02fc1..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/widgets/staff_permissions_section.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; - -class StaffPermissionsSection extends StatelessWidget { - final Set selectedPermissions; - final Function(Permission, bool) onPermissionChanged; - - const StaffPermissionsSection({ - super.key, - required this.selectedPermissions, - required this.onPermissionChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 20), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 3, - thickness: 2.5, - ), - const SizedBox(height: 10), - Text( - 'Staff Permissions', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ...Permission.values.map((permission) { - return CheckboxListTile( - controlAffinity: ListTileControlAffinity.leading, - checkColor: Theme.of(context).colorScheme.surface, - contentPadding: EdgeInsets.zero, - title: Text( - permission.name - .replaceAll('_', ' ') - .toLowerCase() - .split(' ') - .map((e) => e[0].toUpperCase() + e.substring(1)) - .join(' '), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - value: selectedPermissions.contains(permission), - onChanged: (v) => onPermissionChanged(permission, v ?? false), - ); - }), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_form.dart b/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_form.dart deleted file mode 100644 index 4a365d60..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_form.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/util/validators.dart'; - -class UpdateInfoForm extends StatelessWidget { - final TextEditingController nameController; - final TextEditingController emailController; - final bool nameSubmitted; - final bool emailSubmitted; - - const UpdateInfoForm({ - super.key, - required this.nameController, - required this.emailController, - required this.nameSubmitted, - required this.emailSubmitted, - }); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - ThemedInputField( - controller: nameController, - labelText: "Name", - hintText: "Ahmed Helmy", - textInputAction: TextInputAction.next, - validator: (input) => validateName(input, isSubmitted: nameSubmitted), - ), - const SizedBox(height: 31), - ThemedInputField( - controller: emailController, - labelText: "Email", - hintText: "ahmedhelmy@twoaxis.org", - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - validator: (input) => validateEmail(input, isSubmitted: emailSubmitted), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_header.dart b/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_header.dart deleted file mode 100644 index 51b627d6..00000000 --- a/mobile/lib/ui/app/single-class/staff/profile/widgets/update_info_header.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; - -class UpdateInfoHeader extends StatelessWidget { - final String name; - - const UpdateInfoHeader({super.key, required this.name}); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - name, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/add_question_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/add_question_screen.dart deleted file mode 100644 index 6dbbe54e..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/add_question_screen.dart +++ /dev/null @@ -1,320 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/util/plan_util.dart'; -import 'package:yaspo_mobile/model/quiz/question.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddQuestionScreen extends StatefulWidget { - const AddQuestionScreen({super.key, this.question}); - - final Question? question; - - @override - State createState() => _AddQuestionScreenState(); -} - -class _AddQuestionScreenState extends State { - final titleController = TextEditingController(); - final pointsController = TextEditingController(); - final modelAnswerController = TextEditingController(); - List answerControllers = []; - int selectedAnswerIndex = 0; - int questionType = 0; // 0 = MCQ, 1 = Written - final formKey = GlobalKey(); - - @override - void initState() { - super.initState(); - if (widget.question != null) { - titleController.text = widget.question!.title; - pointsController.text = widget.question!.points.toString(); - modelAnswerController.text = widget.question!.modelAnswer ?? ''; - selectedAnswerIndex = widget.question!.correctAnswerIndex; - questionType = widget.question!.type; - for (var answer in widget.question!.answers) { - answerControllers.add(TextEditingController(text: answer.text)); - } - } else { - // Default 2 options - answerControllers.add(TextEditingController()); - answerControllers.add(TextEditingController()); - } - } - - @override - void dispose() { - titleController.dispose(); - pointsController.dispose(); - modelAnswerController.dispose(); - for (var c in answerControllers) { - c.dispose(); - } - super.dispose(); - } - - void _addOption() { - setState(() { - answerControllers.add(TextEditingController()); - }); - } - - void _removeOption(int index) { - if (answerControllers.length <= 2) return; // Min 2 options - setState(() { - answerControllers[index].dispose(); - answerControllers.removeAt(index); - if (selectedAnswerIndex >= index && selectedAnswerIndex > 0) { - selectedAnswerIndex--; - } - if (selectedAnswerIndex >= answerControllers.length) { - selectedAnswerIndex = answerControllers.length - 1; - } - }); - } - - void _save() { - if (!formKey.currentState!.validate()) return; - - if (questionType == 0) { - for (var controller in answerControllers) { - if (controller.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('All options must be filled')), - ); - return; - } - } - } - - // Sync answers list with controllers - // If editing, we might want to preserve IDs if possible, but the order might change if we removed/added in middle. - // Simpler approach: Reconstruct list. If editing and count matches and we assume order preserved, we can try to reuse ID. - // But since we can remove from middle, index mapping is hard. - // However, for Quiz creation, IDs don't matter much until saved. - // If we are editing an existing question from API, we should preserve IDs. - // But `AddQuestionScreen` just returns a `Question` object. - - // Let's assume we just create new Answer objects with text. - // If we want to support editing existing questions with IDs, we need to track IDs along with controllers. - - List newAnswers = []; - // If we have existing answers, we try to match by index if length matches? No that's risky. - // For now, let's just create new objects. The API handles updates by ID usually but here we might send full list? - // The API `PUT /quizzes` expects strings for answers. - // The `PATCH /quizzes/{id}` expects strings too. - // Wait, `GET` returns objects with IDs. - // If I change an answer, does it update the object or create new one? - // If I send list of strings, backend likely recreates them or diffs them. - // So ID on Answer might not be crucial for sending back, only for receiving. - - // Wait, `PATCH` example shows `answers` as list of strings. - // So IDs for answers are likely ignored or not sent in update. - - for (int i = 0; i < answerControllers.length; i++) { - newAnswers.add(Answer(text: answerControllers[i].text.trim())); - } - - var question = Question( - id: widget.question?.id, - title: titleController.text.trim(), - correctAnswerIndex: questionType == 0 ? selectedAnswerIndex : 0, - points: int.tryParse(pointsController.text.trim()) ?? 1, - type: questionType, - modelAnswer: modelAnswerController.text.trim(), - answers: questionType == 0 ? newAnswers : [], - ); - - Navigator.pop(context, question); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: fullscreenSpacing, - vertical: 12, - ), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Text( - widget.question == null ? 'Add Question' : 'Edit Question', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: fullscreenSpacing, - ), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedInputField( - labelText: 'Question Title', - hintText: 'What is the capital of France?', - controller: titleController, - textInputAction: TextInputAction.next, - validator: (val) => - val!.trim().isEmpty ? 'Title is required' : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Points', - hintText: '1', - controller: pointsController, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.number, - validator: (val) { - if (val!.trim().isEmpty) return 'Points required'; - if (int.tryParse(val) == null) { - return 'Must be a number'; - } - return null; - }, - ), - const SizedBox(height: 20), - Text( - 'Question Type', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: 0, - label: Text('Multiple Choice'), - ), - ButtonSegment( - value: 1, - label: Text('Written'), - ), - ], - selected: {questionType}, - onSelectionChanged: (Set newSelection) { - setState(() { - questionType = newSelection.first; - }); - }, - ), - ), - const SizedBox(height: 24), - if (questionType == 0) ...[ - Text( - 'Options', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: answerControllers.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - return Row( - children: [ - Radio( - value: index, - // ignore: deprecated_member_use - groupValue: selectedAnswerIndex, - // ignore: deprecated_member_use - onChanged: (val) { - setState(() { - selectedAnswerIndex = val!; - }); - }, - ), - Expanded( - child: ThemedInputField( - labelText: 'Option ${index + 1}', - hintText: 'Enter option', - controller: answerControllers[index], - textInputAction: TextInputAction.done, - validator: (val) => - val!.trim().isEmpty ? 'Required' : null, - ), - ), - if (answerControllers.length > 2) - IconButton( - icon: const Icon(Icons.delete_outline), - color: Colors.red, - onPressed: () => _removeOption(index), - ), - ], - ); - }, - ), - const SizedBox(height: 12), - TextButton.icon( - onPressed: _addOption, - icon: const Icon(Icons.add), - label: const Text('Add Option'), - ), - ] else ...[ - const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'Students will provide a written answer for this question.', - style: TextStyle(fontStyle: FontStyle.italic, color: Colors.grey), - ), - ), - if (PlanUtil.isAIQuizAllowed(context.watch().state?.tenant?.plan)) ...[ - const SizedBox(height: 16), - ThemedInputField( - labelText: 'Model Answer (Optional - used for AI grading)', - hintText: 'Enter an ideal answer...', - controller: modelAnswerController, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.done, - ), - ], - ], - const SizedBox(height: 24), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: PrimaryButton(text: 'Save Question', onPressed: _save), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart deleted file mode 100644 index 31d81307..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart +++ /dev/null @@ -1,679 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/util/plan_util.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/exceptions/quiz_exception.dart'; -import 'package:yaspo_mobile/model/quiz/question.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/add_question_screen.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; -import 'package:dio/dio.dart'; - -class CreateEditQuizScreen extends StatefulWidget { - const CreateEditQuizScreen({super.key, this.quiz, this.classId = 'single'}); - - final Quiz? quiz; - final String classId; - - @override - State createState() => _CreateEditQuizScreenState(); -} - -class _CreateEditQuizScreenState extends State { - final titleController = TextEditingController(); - final timeLimitController = TextEditingController(); - final startsAtController = TextEditingController(); - final endsAtController = TextEditingController(); - final aiPromptController = TextEditingController(); - - DateTime? startsAt; - bool showAIPrompt = false; - bool isGenerating = false; - DateTime? endsAt; - bool isPublished = false; - bool autoGrade = false; - bool showMarksAtEnd = true; - - List questions = []; - bool isLoading = false; - bool isSubmitting = false; - final formKey = GlobalKey(); - - @override - void initState() { - super.initState(); - if (widget.quiz != null) { - _fetchQuizDetails(); - } else { - // Defaults - startsAt = DateTime.now().add(const Duration(minutes: 5)); - endsAt = DateTime.now().add(const Duration(hours: 1, minutes: 5)); - _updateDateControllers(); - } - } - - Future _fetchQuizDetails() async { - setState(() => isLoading = true); - try { - var fullQuiz = await GetIt.I().getQuiz(widget.quiz!.id); - if (mounted) { - GetIt.I().trackEvent( - 'fetch_quiz_details', - parameters: {'status': 'success'}, - ); - setState(() { - titleController.text = fullQuiz.title; - timeLimitController.text = - fullQuiz.timeLimitInMinutes?.toString() ?? ''; - isPublished = fullQuiz.isPublished; - autoGrade = fullQuiz.autoGrade ?? false; - showMarksAtEnd = fullQuiz.showMarksAtEnd ?? true; - startsAt = fullQuiz.startsAt; - endsAt = fullQuiz.endsAt; - questions = fullQuiz.questions ?? []; - _updateDateControllers(); - isLoading = false; - }); - } - } catch (e) { - GetIt.I().trackEvent( - 'fetch_quiz_details', - parameters: {'status': 'failed'}, - ); - if (mounted) { - setState(() => isLoading = false); - Navigator.pop(context); // Close if failed - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to load quiz details')), - ); - } - } - } - - void _updateDateControllers() { - if (startsAt != null) { - startsAtController.text = DateFormat( - 'dd/MM/yyyy HH:mm', - ).format(startsAt!); - } - if (endsAt != null) { - endsAtController.text = DateFormat('dd/MM/yyyy HH:mm').format(endsAt!); - } - } - - @override - void dispose() { - titleController.dispose(); - timeLimitController.dispose(); - startsAtController.dispose(); - endsAtController.dispose(); - aiPromptController.dispose(); - super.dispose(); - } - - Future _pickDateTime(bool isStart) async { - var initial = isStart - ? (startsAt ?? DateTime.now()) - : (endsAt ?? DateTime.now()); - var date = await showDatePicker( - context: context, - initialDate: initial, - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime(2100), - ); - if (date == null) return; - - if (!mounted) return; - - var time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(initial), - ); - if (time == null) return; - - var result = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - - setState(() { - if (isStart) { - startsAt = result; - } else { - endsAt = result; - } - _updateDateControllers(); - }); - } - - Future _addOrEditQuestion({Question? question, int? index}) async { - var result = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => AddQuestionScreen(question: question)), - ); - - GetIt.I().trackEvent( - 'add_or_edit_question', - parameters: {'status': 'success'}, - ); - - if (result != null) { - setState(() { - if (index != null) { - questions[index] = result; - } else { - questions.add(result); - } - }); - } - } - - Future _generateAIQuestions() async { - var prompt = aiPromptController.text.trim(); - if (prompt.isEmpty) return; - - setState(() => isGenerating = true); - try { - var generated = await GetIt.I().generateQuestions( - prompt, - widget.classId, - ); - await GetIt.I().trackEvent( - 'generate_questions_ai', - parameters: {'status': 'success', 'count': generated.length}, - ); - if (mounted) { - setState(() { - questions.addAll(generated); - aiPromptController.clear(); - showAIPrompt = false; - isGenerating = false; - }); - } - } catch (e) { - await GetIt.I().trackEvent( - 'generate_questions_ai', - parameters: {'status': 'failed'}, - ); - if (mounted) { - setState(() => isGenerating = false); - String msg = 'Failed to generate questions. Make sure the Gemini API key is configured.'; - if (e is DioException) { - if (e.response?.statusCode == 429 || e.response?.statusCode == 503) { - msg = 'The AI model is currently experiencing high traffic. Please try again later.'; - } else if (e.response?.data is Map && e.response?.data['error'] != null) { - msg = e.response!.data['error'].toString(); - } - } - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: msg, - onComplete: (_) {}, - ); - } - } - } - - Future _submit() async { - if (!formKey.currentState!.validate()) return; - if (startsAt == null || endsAt == null) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Dates are required'))); - return; - } - if (!endsAt!.isAfter(startsAt!)) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('End date must be after start date')), - ); - return; - } - if (widget.quiz == null && startsAt!.isBefore(DateTime.now().subtract(const Duration(minutes: 5)))) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Start date cannot be in the past')), - ); - return; - } - if (questions.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please add at least one question')), - ); - return; - } - - setState(() => isSubmitting = true); - - try { - if (widget.quiz == null) { - await GetIt.I().createQuiz( - titleController.text.trim(), - isPublished, - startsAt!, - endsAt!, - int.tryParse(timeLimitController.text.trim()) ?? 0, - questions, - (PlanUtil.isAIQuizAllowed(context.read().state?.tenant?.plan) && questions.any((q) => q.type == 1)) && autoGrade, - showMarksAtEnd, - widget.classId, - ); - } else { - await GetIt.I().updateQuiz( - widget.quiz!.id, - titleController.text.trim(), - isPublished, - startsAt!, - endsAt!, - int.tryParse(timeLimitController.text.trim()) ?? 0, - questions, - (PlanUtil.isAIQuizAllowed(context.read().state?.tenant?.plan) && questions.any((q) => q.type == 1)) && autoGrade, - showMarksAtEnd, - widget.classId, - ); - } - await GetIt.I().trackEvent( - 'add_or_update_quiz', - parameters: {'status': 'success'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: widget.quiz == null - ? 'Quiz created successfully' - : 'Quiz updated successfully', - onComplete: (_) { - Navigator.pop(context, true); // Return true to signal refresh - }, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'add_or_update_quiz', - parameters: {'status': 'failed'}, - ); - if (mounted) { - String msg = 'Failed to save quiz'; - if (e is QuizUpdateFailed) msg = e.message; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: msg, - onComplete: (_) {}, - ); - } - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - Widget build(BuildContext context) { - if (isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); - } - - var canAutoGrade = PlanUtil.isAIQuizAllowed(context.watch().state?.tenant?.plan) && questions.any((q) => q.type == 1); - - return Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: fullscreenSpacing, - vertical: 12, - ), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Text( - widget.quiz == null ? 'Create Quiz' : 'Edit Quiz', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: fullscreenSpacing, - ), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedInputField( - labelText: 'Quiz Title', - hintText: 'Midterm Exam', - controller: titleController, - textInputAction: TextInputAction.next, - validator: (val) => - val!.trim().isEmpty ? 'Title is required' : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Start Date & Time', - hintText: 'Select date', - controller: startsAtController, - textInputAction: TextInputAction.next, - readonly: true, - suffixIcon: Icons.calendar_today, - ontap: () => _pickDateTime(true), - validator: (val) => - val!.trim().isEmpty ? 'Required' : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'End Date & Time', - hintText: 'Select date', - controller: endsAtController, - textInputAction: TextInputAction.next, - readonly: true, - suffixIcon: Icons.calendar_today, - ontap: () => _pickDateTime(false), - validator: (val) => - val!.trim().isEmpty ? 'Required' : null, - ), - const SizedBox(height: 20), - ThemedInputField( - labelText: 'Time Limit (Minutes)', - hintText: '60', - controller: timeLimitController, - textInputAction: TextInputAction.done, - keyboardType: TextInputType.number, - validator: (val) { - if (val!.trim().isEmpty) return 'Required'; - int? parsed = int.tryParse(val); - if (parsed == null) { - return 'Must be a number'; - } - if (parsed <= 0) { - return 'Must be > 0'; - } - return null; - }, - ), - const SizedBox(height: 10), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Published", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: isPublished, - onChanged: (value) { - setState(() { - isPublished = value!; - }); - }, - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Auto Grade Written Answers (Requires AI & Written Questions)", - style: TextStyle( - color: canAutoGrade ? Theme.of(context).colorScheme.onSurface : Colors.grey, - fontSize: 16, - ), - ), - value: canAutoGrade && autoGrade, - onChanged: canAutoGrade ? (value) { - setState(() { - autoGrade = value!; - }); - } : null, - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Show Score to Students Immediately", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: showMarksAtEnd, - onChanged: (value) { - setState(() { - showMarksAtEnd = value!; - }); - }, - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Questions (${questions.length})', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: () => _addOrEditQuestion(), - ), - ], - ), - const SizedBox(height: 8), - if (PlanUtil.isAIQuizAllowed(context.watch().state?.tenant?.plan)) - if (!showAIPrompt) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(50), - side: BorderSide( - color: Theme.of(context).colorScheme.primary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - icon: const Icon(Icons.auto_awesome), - label: const Text('Auto-Generate with AI'), - onPressed: () { - setState(() { - showAIPrompt = true; - }); - }, - ), - ) - else - Card( - elevation: 0, - color: Theme.of(context).colorScheme.primary.withAlpha(12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: Theme.of(context).colorScheme.primary.withAlpha(50), - ), - ), - margin: const EdgeInsets.only(bottom: 16), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.auto_awesome, - color: Theme.of(context).colorScheme.primary, - size: 20, - ), - const SizedBox(width: 8), - Text( - 'Generate Questions with AI', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 12), - ThemedInputField( - labelText: 'Prompt', - hintText: 'E.g., Create 3 multiple choice questions about the solar system', - controller: aiPromptController, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.done, - readonly: isGenerating, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: isGenerating ? null : _generateAIQuestions, - child: isGenerating - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : const Text('Generate'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onPressed: isGenerating - ? null - : () { - setState(() { - showAIPrompt = false; - }); - }, - child: const Text('Cancel'), - ), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 8), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: questions.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - var q = questions[index]; - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - borderRadius: BorderRadius.circular(8), - ), - child: ListTile( - title: Text(q.title), - subtitle: Text('${q.points} points'), - trailing: IconButton( - icon: const Icon( - Icons.delete_outline, - color: Colors.red, - ), - onPressed: () { - setState(() { - questions.removeAt(index); - }); - }, - ), - onTap: () => - _addOrEditQuestion(question: q, index: index), - ), - ); - }, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: PrimaryButton( - text: widget.quiz == null ? 'Create Quiz' : 'Update Quiz', - onPressed: _submit, - enabled: !isSubmitting, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_details_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/quiz_details_screen.dart deleted file mode 100644 index 7555e7db..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_details_screen.dart +++ /dev/null @@ -1,316 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/submissions/quiz_submissions_screen.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/show_alert_dialog.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class QuizDetailsScreen extends StatefulWidget { - const QuizDetailsScreen({super.key, required this.quiz, this.classId = 'single'}); - - final Quiz quiz; - final String classId; - - @override - State createState() => _QuizDetailsScreenState(); -} - -class _QuizDetailsScreenState extends State { - late Quiz quiz; - bool loading = false; - - @override - void initState() { - super.initState(); - quiz = widget.quiz; - _refresh(); - } - - Future _refresh() async { - setState(() => loading = true); - try { - var updatedQuiz = await GetIt.I().getQuiz( - quiz.id, - widget.classId, - ); - if (mounted) { - setState(() { - quiz = updatedQuiz; - }); - } - } catch (_) { - // Keep old data if fail - } finally { - if (mounted) setState(() => loading = false); - } - } - - Future _deleteQuiz() async { - var confirm = await showCustomDialog( - context: context, - title: 'Delete Quiz?', - message: 'This action cannot be undone.', - confirmText: 'Delete', - ); - if (confirm == null || !confirm.$1) return; - - try { - await GetIt.I().deleteQuiz(quiz.id, widget.classId); - await GetIt.I().trackEvent( - 'delete_quiz', - parameters: {'status': 'success'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Quiz deleted successfully', - onComplete: (_) { - Navigator.pop(context, true); // Pop to list and refresh - }, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'delete_quiz', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Failed to delete quiz', - onComplete: (_) {}, - ); - } - } - } - - @override - Widget build(BuildContext context) { - var dateFormat = DateFormat('MMM dd, yyyy HH:mm'); - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop( - context, - true, - ), // Refresh list on return - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Quiz Details', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - Expanded( - child: RefreshIndicator( - onRefresh: _refresh, - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: loading - ? QuizDetailsSkeleton() - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - quiz.title, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 12), - _buildStatusBadge(context), - const SizedBox(height: 24), - _buildDetailRow( - 'Total Points', - '${quiz.totalPoints}', - ), - const SizedBox(height: 12), - _buildDetailRow( - 'Questions', - '${quiz.questions?.length ?? quiz.questionsCount ?? 0}', - ), - const SizedBox(height: 12), - _buildDetailRow( - 'Time Limit', - '${quiz.timeLimitInMinutes} mins', - ), - const SizedBox(height: 12), - if (quiz.startsAt != null) - _buildDetailRow( - 'Starts At', - dateFormat.format(quiz.startsAt!), - ), - const SizedBox(height: 12), - if (quiz.endsAt != null) - _buildDetailRow( - 'Ends At', - dateFormat.format(quiz.endsAt!), - ), - const SizedBox(height: 32), - PrimaryButton( - text: 'View Submissions', - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => QuizSubmissionsScreen( - quizId: quiz.id, - classId: widget.classId, - ), - ), - ); - }, - ), - const SizedBox(height: 16), - BlocBuilder( - builder: (context, user) { - if (user?.permissions.contains( - Permission.EDUCATOR, - ) ?? - false) { - return Column( - children: [ - PrimaryButton( - text: 'Update Quiz', - enabled: !quiz.isPublished, - onPressed: () async { - var result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - CreateEditQuizScreen( - quiz: quiz, - classId: widget.classId, - ), - ), - ); - if (result == true) { - _refresh(); - } - }, - backgroundColor: Theme.of( - context, - ).colorScheme.secondary, - textColor: Theme.of( - context, - ).colorScheme.onSecondary, - ), - const SizedBox(height: 16), - PrimaryButton( - text: 'Delete Quiz', - onPressed: _deleteQuiz, - backgroundColor: Colors.red, - textColor: Colors.white, - ), - ], - ); - } - return const SizedBox.shrink(); - }, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildStatusBadge(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: quiz.isPublished - ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.1) - : Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: quiz.isPublished - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.tertiary, - ), - ), - child: Text( - quiz.isPublished ? 'Published' : 'Draft', - style: TextStyle( - color: quiz.isPublished - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.tertiary, - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ); - } - - Widget _buildDetailRow(String label, String value) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - label, - style: TextStyle( - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart b/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart deleted file mode 100644 index 57f2bb8b..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/quiz_widget.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/quiz_details_screen.dart'; - -class QuizWidget extends StatelessWidget { - const QuizWidget({ - super.key, - required this.quiz, - required this.onRefresh, - this.classId = 'single', - }); - - final Quiz quiz; - final VoidCallback onRefresh; - final String classId; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () async { - var result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => QuizDetailsScreen(quiz: quiz, classId: classId), - ), - ); - if (result == true) { - onRefresh(); - } - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - quiz.title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - '${quiz.questionsCount ?? 0} Questions • ${quiz.totalPoints} Points', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - const SizedBox(width: 10), - Text( - quiz.isPublished ? 'Published' : 'Draft', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: quiz.isPublished - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.tertiary, - ), - ), - const SizedBox(width: 8), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/quizzes_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/quizzes_screen.dart deleted file mode 100644 index 3d95967c..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/quizzes_screen.dart +++ /dev/null @@ -1,221 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/theme/color_scheme.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/create_edit_quiz_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/quiz_widget.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class QuizzesScreen extends StatefulWidget { - const QuizzesScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _QuizzesScreenState(); -} - -class _QuizzesScreenState extends State { - bool loading = false; - bool error = false; - List quizzes = []; - - @override - void initState() { - super.initState(); - fetchQuizzes(); - } - - Future fetchQuizzes() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getQuizzes(widget.classId); - await GetIt.I().trackEvent( - 'fetch_quizzes', - parameters: {'type': 'staff', 'status': 'success'}, - ); - if (mounted) { - setState(() { - quizzes = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_quizzes', - parameters: {'type': 'staff', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - _buildHeader(context), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchQuizzes, - color: colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Text( - 'Quizzes', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - BlocBuilder( - builder: (context, user) { - if (user?.permissions.contains(Permission.EDUCATOR) ?? false) { - return IconButton( - icon: Icon( - Icons.add, - size: 32, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () async { - var result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CreateEditQuizScreen(classId: widget.classId), - ), - ); - - if (result == true && context.mounted) { - fetchQuizzes(); - } - }, - ); - } - return const SizedBox(); - }, - ), - ], - ); - } - - Widget _buildContent() { - if (loading && quizzes.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const QuizSkeleton(), - ); - } - - if (error && quizzes.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "Failed to load quizzes.\nPlease try again later.", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - if (quizzes.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "No quizzes yet. Create one!", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: quizzes.length, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) { - var quiz = quizzes[index]; - return QuizWidget( - quiz: quiz, - onRefresh: fetchQuizzes, - classId: widget.classId, - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/submissions/quiz_submissions_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/submissions/quiz_submissions_screen.dart deleted file mode 100644 index 2a974546..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/submissions/quiz_submissions_screen.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/model/quiz/submission.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/quizzes/submissions/submission_details_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class QuizSubmissionsScreen extends StatefulWidget { - const QuizSubmissionsScreen({ - super.key, - required this.quizId, - this.classId = 'single', - }); - - final String quizId; - final String classId; - - @override - State createState() => _QuizSubmissionsScreenState(); -} - -class _QuizSubmissionsScreenState extends State { - List submissions = []; - bool loading = true; - bool error = false; - - late final bool isStudent; - - @override - void initState() { - super.initState(); - _fetchSubmissions(); - } - - Future _fetchSubmissions() async { - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getSubmissions( - widget.quizId, - widget.classId, - ); - await GetIt.I().trackEvent( - 'fetch_submissions', - parameters: {'status': 'success'}, - ); - if (mounted) { - setState(() { - submissions = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_submissions', - parameters: {'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - var dateFormat = DateFormat('MMM dd, yyyy HH:mm'); - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Submissions', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - Expanded( - child: loading - ? const Center(child: CircularProgressIndicator()) - : error - ? Center( - child: Text( - 'Failed to load submissions', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ) - : submissions.isEmpty - ? Center( - child: Text( - 'No submissions yet', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ) - : ListView.separated( - itemCount: submissions.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - var sub = submissions[index]; - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - borderRadius: BorderRadius.circular(8), - ), - child: ListTile( - title: Text(sub.studentName), - subtitle: Text( - 'Submitted: ${dateFormat.format(sub.submittedAt)}', - ), - trailing: Text( - 'Score: ${sub.totalScore}', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SubmissionDetailsScreen( - quizId: widget.quizId, - submissionId: sub.id, - classId: widget.classId, - ), - ), - ); - }, - ), - ); - }, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/quizzes/submissions/submission_details_screen.dart b/mobile/lib/ui/app/single-class/staff/quizzes/submissions/submission_details_screen.dart deleted file mode 100644 index beb0c0f9..00000000 --- a/mobile/lib/ui/app/single-class/staff/quizzes/submissions/submission_details_screen.dart +++ /dev/null @@ -1,459 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/model/quiz/question.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/model/quiz/submission.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class SubmissionDetailsScreen extends StatefulWidget { - const SubmissionDetailsScreen({ - super.key, - required this.quizId, - required this.submissionId, - this.classId = 'single', - }); - - final String quizId; - final String submissionId; - final String classId; - - @override - State createState() => - _SubmissionDetailsScreenState(); -} - -class _SubmissionDetailsScreenState extends State { - SubmissionDetail? submission; - Quiz? quiz; - bool loading = true; - bool error = false; - bool saving = false; - Map editedGrades = {}; - - @override - void initState() { - super.initState(); - _loadData(); - } - - Future _loadData() async { - setState(() { - loading = true; - error = false; - }); - - try { - var quizData = await GetIt.I().getQuiz( - widget.quizId, - widget.classId, - ); - var subData = await GetIt.I().getSubmission( - widget.quizId, - widget.submissionId, - widget.classId, - ); - await GetIt.I().trackEvent( - 'load_quiz_data', - parameters: {'status': 'success'}, - ); - if (mounted) { - setState(() { - quiz = quizData; - submission = subData; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'load_quiz_data', - parameters: {'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: fullscreenSpacing, - vertical: 12, - ), - child: Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Submission Details', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ), - ), - Expanded( - child: loading - ? const Center(child: CircularProgressIndicator()) - : error || submission == null || quiz == null - ? Center( - child: Text( - 'Failed to load details', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ) - : _buildContent(), - ), - ], - ), - ), - ); - } - - Widget _buildContent() { - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - submission!.studentName, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - 'Score: ${submission!.totalScore} / ${quiz!.totalPoints}', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - if (submission!.needsManualGrading || editedGrades.isNotEmpty) - ElevatedButton( - onPressed: saving ? null : _saveGrades, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.primary, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - child: saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text('Save Grades'), - ), - ], - ), - const SizedBox(height: 24), - Text( - 'Answers:', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 16), - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: quiz!.questions?.length ?? 0, - separatorBuilder: (context, index) => const SizedBox(height: 24), - itemBuilder: (context, index) { - var question = quiz!.questions![index]; - var answer = submission!.answers.firstWhere( - (a) => a.questionId == question.id, - orElse: () => SubmissionAnswer( - questionId: question.id!, - selectedAnswerIndex: -1, - ), - ); - - return _buildQuestionResult(question, answer, index + 1); - }, - ), - const SizedBox(height: 24), - ], - ), - ); - } - - Widget _buildQuestionResult( - Question question, - SubmissionAnswer answer, - int number, - ) { - bool isWritten = question.type == 1; - bool isCorrect = false; - bool isUnanswered = false; - - if (isWritten) { - isUnanswered = answer.writtenAnswer == null || answer.writtenAnswer!.trim().isEmpty; - isCorrect = (answer.pointsAwarded ?? 0) > 0; - } else { - isCorrect = answer.selectedAnswerIndex == question.correctAnswerIndex; - isUnanswered = answer.selectedAnswerIndex == -1; - } - - Color borderColor; - if (isUnanswered) { - borderColor = Theme.of(context).colorScheme.outline; - } else if (isWritten && answer.pointsAwarded == null) { - borderColor = Colors.orange; // Needs manual grading - } else if (isCorrect) { - borderColor = Colors.green; - } else { - borderColor = Colors.red; - } - - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all( - color: borderColor, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - '$number. ${question.title}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Text( - '${isWritten ? ((editedGrades[question.id!] ?? answer.pointsAwarded)?.toString() ?? '?') : (isCorrect ? question.points : 0)} / ${question.points} pts', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - const SizedBox(height: 12), - if (isWritten) ...[ - Row( - children: [ - const Text('Award Points: '), - const SizedBox(width: 8), - SizedBox( - width: 80, - child: TextFormField( - initialValue: (answer.pointsAwarded ?? 0).toString(), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - TextInputFormatter.withFunction((oldValue, newValue) { - if (newValue.text.isEmpty) return newValue; - final pts = int.tryParse(newValue.text); - if (pts == null || pts < 0 || pts > question.points) { - return oldValue; - } - return newValue; - }), - ], - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onChanged: (val) { - var pts = int.tryParse(val); - if (pts != null) { - setState(() { - editedGrades[question.id!] = pts; - }); - } - }, - ), - ), - const SizedBox(width: 8), - Text('/ ${question.points}'), - ], - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - width: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Theme.of(context).colorScheme.outline), - ), - child: Text( - isUnanswered ? 'No answer provided.' : answer.writtenAnswer!, - style: TextStyle( - color: isUnanswered ? Colors.grey : Theme.of(context).colorScheme.onSurface, - fontStyle: isUnanswered ? FontStyle.italic : FontStyle.normal, - ), - ), - ), - if (question.modelAnswer != null && question.modelAnswer!.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - 'Model Answer:', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.all(12), - width: double.infinity, - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.green.withValues(alpha: 0.3)), - ), - child: Text( - question.modelAnswer!, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ] else ...List.generate(question.answers.length, (i) { - bool isSelected = i == answer.selectedAnswerIndex; - bool isCorrectOption = i == question.correctAnswerIndex; - - Color color = Colors.transparent; - if (isSelected) { - color = isCorrect - ? Colors.green.withValues(alpha: 0.2) - : Colors.red.withValues(alpha: 0.2); - } - if (isCorrectOption && !isCorrect && !isUnanswered) { - // Highlight correct answer if user was wrong - color = Colors.green.withValues(alpha: 0.1); - } - - return Container( - margin: const EdgeInsets.only(bottom: 4), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - border: isSelected || isCorrectOption - ? Border.all( - color: isCorrectOption ? Colors.green : Colors.red, - width: isSelected ? 2 : 1, - ) - : null, - ), - child: Row( - children: [ - Icon( - isSelected - ? (isCorrect ? Icons.check_circle : Icons.cancel) - : (isCorrectOption - ? Icons.check_circle_outline - : Icons.radio_button_unchecked), - size: 16, - color: isSelected - ? (isCorrect ? Colors.green : Colors.red) - : (isCorrectOption - ? Colors.green - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5)), - ), - const SizedBox(width: 8), - Expanded(child: Text(question.answers[i].text)), - ], - ), - ); - }), - ], - ), - ); - } - - Future _saveGrades() async { - if (editedGrades.isEmpty) return; - - for (var entry in editedGrades.entries) { - var q = quiz!.questions!.firstWhere((q) => q.id == entry.key); - if (entry.value < 0 || entry.value > q.points) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Points for "${q.title}" must be between 0 and ${q.points}'), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - return; - } - } - - setState(() => saving = true); - try { - await GetIt.I().gradeSubmission( - widget.quizId, - widget.submissionId, - editedGrades, - widget.classId, - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Grades saved successfully')), - ); - } - await _loadData(); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save grades: $e')), - ); - } - } finally { - if (mounted) setState(() => saving = false); - } - } -} diff --git a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart deleted file mode 100644 index a8deaf24..00000000 --- a/mobile/lib/ui/app/single-class/staff/staff/add_staff_screen.dart +++ /dev/null @@ -1,323 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/staff_service.dart'; -import 'package:yaspo_mobile/enums/permission.dart'; -import 'package:yaspo_mobile/exceptions/student_exception.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/util/validators.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddStaffScreen extends StatefulWidget { - const AddStaffScreen({super.key}); - - @override - State createState() => _AddStaffScreenState(); -} - -class _AddStaffScreenState extends State { - final formKey = GlobalKey(); - final nameController = TextEditingController(); - final emailController = TextEditingController(); - final passwordController = TextEditingController(); - - bool autoGeneratePassword = false; - bool sendInstructions = false; - bool isSubmitting = false; - bool emailSubmitted = false; - final Set selectedPermissions = {}; - final Set selectedClassIds = {}; - - bool get isFormValid { - if (nameController.text.trim().isEmpty) return false; - if (emailController.text.trim().isEmpty) return false; - if (!autoGeneratePassword && passwordController.text.trim().isEmpty) { - return false; - } - if (selectedPermissions.isEmpty) return false; - return true; - } - - @override - void initState() { - super.initState(); - emailController.addListener(() { - if (emailSubmitted) { - emailSubmitted = false; - formKey.currentState?.validate(); - } - }); - nameController.addListener(() => setState(() {})); - passwordController.addListener(() => setState(() {})); - } - - Future submitStaff() async { - setState(() => emailSubmitted = true); - if (!formKey.currentState!.validate()) return; - - if (selectedPermissions.isEmpty) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Staff must have at least one permission", - onComplete: (_) {}, - ); - return; - } - - setState(() => isSubmitting = true); - try { - var newStaff = await GetIt.I().addStaff( - nameController.text.trim(), - emailController.text.trim(), - autoGeneratePassword, - autoGeneratePassword ? null : passwordController.text.trim(), - sendInstructions, - permissions: selectedPermissions.toList(), - ); - await GetIt.I().trackEvent( - 'add_staff', - parameters: { - 'status': 'success', - 'is_educator': selectedPermissions - .contains(Permission.EDUCATOR) - .toString(), - 'class_count': selectedClassIds.length, - }, - ); - if (!mounted) return; - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Staff added successfully", - onComplete: (_) { - if (mounted) Navigator.pop(context, newStaff); - }, - ); - } on EmailAlreadyExist { - await GetIt.I().trackEvent( - 'add_staff', - parameters: {'status': 'failed', 'error': 'staff_exists'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "E-mail is already used", - onComplete: (_) {}, - ); - } - } catch (_) { - await GetIt.I().trackEvent( - 'add_staff', - parameters: {'status': 'failed', 'error': 'unknown_error'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void dispose() { - nameController.dispose(); - emailController.dispose(); - passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'Add Staff', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 12), - ThemedInputField( - controller: nameController, - labelText: "Name", - textInputAction: TextInputAction.next, - hintText: "Ahmed Elbelehy", - ), - const SizedBox(height: 20), - ThemedInputField( - controller: emailController, - labelText: "E-mail", - textInputAction: TextInputAction.next, - hintText: "ahmedElbelehy@twoaxis.org", - validator: (input) => - validateEmail(input, isSubmitted: emailSubmitted), - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Autogenerate a password", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: autoGeneratePassword, - onChanged: (value) { - setState(() { - autoGeneratePassword = value!; - if (autoGeneratePassword) sendInstructions = true; - }); - }, - ), - if (!autoGeneratePassword) ...[ - ThemedInputField( - controller: passwordController, - labelText: "Password", - textInputAction: TextInputAction.done, - hintText: "•••••••••••••••••", - ), - ], - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of( - context, - ).colorScheme.primary.withAlpha(204), - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Send user instructions on how to login.", - style: TextStyle( - color: autoGeneratePassword - ? Theme.of( - context, - ).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: sendInstructions, - onChanged: autoGeneratePassword - ? null - : (v) => setState(() => sendInstructions = v!), - ), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 3, - thickness: 2.5, - ), - const SizedBox(height: 10), - Text( - 'Staff Permissions', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Column( - children: Permission.values.map((permission) { - return CheckboxListTile( - controlAffinity: ListTileControlAffinity.leading, - checkColor: Theme.of(context).colorScheme.surface, - contentPadding: EdgeInsets.zero, - title: Text( - permission.name - .replaceAll('_', ' ') - .toLowerCase() - .split(' ') - .map( - (e) => - e[0].toUpperCase() + e.substring(1), - ) - .join(' '), - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - value: selectedPermissions.contains(permission), - onChanged: (v) { - setState(() { - if (v!) { - selectedPermissions.add(permission); - } else { - selectedPermissions.remove(permission); - if (permission == Permission.EDUCATOR) { - selectedClassIds.clear(); - } - } - }); - }, - ); - }).toList(), - ), - - const SizedBox(height: 20), - ], - ), - ), - ), - PrimaryButton( - text: isSubmitting ? "Adding..." : "New member", - onPressed: submitStaff, - enabled: !isSubmitting && isFormValid, - ), - const SizedBox(height: 24), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/staff/staff_header.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_header.dart deleted file mode 100644 index 812b865f..00000000 --- a/mobile/lib/ui/app/single-class/staff/staff/staff_header.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff/add_staff_screen.dart'; - -class StaffHeader extends StatelessWidget { - const StaffHeader({super.key, required this.addStaff, required this.limitReached}); - final Function addStaff; - final bool limitReached; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'Staff', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - IconButton( - icon: Icon( - Icons.add, - size: 30, - color: limitReached ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5) : Theme.of(context).colorScheme.onSurface, - ), - onPressed: limitReached ? null : () async { - var newStaff = await Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AddStaffScreen()), - ); - - if (newStaff != null) { - addStaff(newStaff); - } - }, - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/staff/staff_screen.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_screen.dart deleted file mode 100644 index 7e6239c2..00000000 --- a/mobile/lib/ui/app/single-class/staff/staff/staff_screen.dart +++ /dev/null @@ -1,181 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/util/plan_util.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/staff_service.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff/staff_header.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff/staff_widget.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class StaffScreen extends StatefulWidget { - const StaffScreen({super.key}); - - @override - State createState() => _StaffScreenState(); -} - -class _StaffScreenState extends State { - List staffs = []; - - bool loading = false; - bool error = false; - - @override - void initState() { - super.initState(); - fetchStaff(); - } - - Future fetchStaff() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getStaff(); - await GetIt.I().trackEvent( - 'fetch_staff', - parameters: {'status': 'success'}, - ); - if (mounted) { - setState(() { - staffs = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_staff', - parameters: {'status': 'failed'}, - ); - if (mounted) setState(() => error = true); - } finally { - if (mounted) setState(() => loading = false); - } - } - - void _addStaff(Staff staff) { - setState(() { - staffs.insert(0, staff); - }); - } - - void _updateStaff(Staff updatedStaff) { - var staffIndex = staffs.indexWhere((s) => s.id == updatedStaff.id); - if (staffIndex != -1) { - setState(() { - staffs[staffIndex] = updatedStaff; - }); - } - } - - @override - Widget build(BuildContext context) { - var user = context.watch().state; - var limitReached = PlanUtil.hasReachedStaffLimit(user?.tenant?.plan, staffs.length); - - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - StaffHeader(addStaff: _addStaff, limitReached: limitReached), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchStaff, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent() { - if (loading && staffs.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const MemberSkeleton(), - ); - } - - if ((error || staffs.isEmpty) && !loading) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - error - ? 'Failed to load staff. Please try again.' - : 'No staff available.', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: staffs.length, - separatorBuilder: (context, index) => - Divider(color: Theme.of(context).colorScheme.outline, height: 2), - itemBuilder: (context, index) { - var staff = staffs[index]; - return StaffWidget( - staff: staff, - onDelete: () { - setState(() { - staffs.removeWhere((s) => s.id == staff.id); - }); - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Staff deleted successfully", - onComplete: (_) { - Navigator.pop(context); - }, - ); - }, - onUpdate: _updateStaff, - ); - }, - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart b/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart deleted file mode 100644 index 426e15bd..00000000 --- a/mobile/lib/ui/app/single-class/staff/staff/staff_widget.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/staff/staff.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/profile_info_screen.dart'; - -class StaffWidget extends StatelessWidget { - const StaffWidget({ - super.key, - required this.staff, - required this.onDelete, - required this.onUpdate, - }); - - final Staff staff; - final VoidCallback onDelete; - final Function onUpdate; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileInfoScreen( - staff: staff, - onDelete: onDelete, - onUpdate: onUpdate, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(25), - child: Image.asset( - "assets/images/profile_picture.png", - width: 50, - height: 50, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Text( - staff.name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 20, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/staff_app_host.dart b/mobile/lib/ui/app/single-class/staff/staff_app_host.dart deleted file mode 100644 index 8fcc3229..00000000 --- a/mobile/lib/ui/app/single-class/staff/staff_app_host.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/announcements/announcements_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/assignments/assignments_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/more/more_screen.dart'; -import 'package:yaspo_mobile/ui/common/meeting/active_call_bar.dart'; - -class StaffAppHost extends StatefulWidget { - const StaffAppHost({super.key, this.classId = 'single', this.className}); - - final String classId; - final String? className; - - @override - State createState() => _App(); -} - -class _App extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - var userCubit = context.read(); - var studentCubit = context.read(); - var studentService = GetIt.I(); - - if (userCubit.state != null) { - try { - var students = await studentService.getStudents(); - studentCubit.setStudents(students); - } on DioException catch (e) { - if (e.response?.statusCode == 400) { - studentCubit.clearStudents(); - } - } - } - }); - } - - int selectedIndex = 0; - - late final List pages; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - pages = [ - AnnouncementScreen(classId: widget.classId), - AssignmentsScreen(classId: widget.classId), - MoreScreen(classId: widget.classId), - ]; - } - - void _onItemTapped(int index) { - if (index == selectedIndex) return; - setState(() { - selectedIndex = index; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: widget.className != null - ? AppBar( - title: Text(widget.className!), - leading: IconButton( - icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - ) - : null, - body: IndexedStack(index: selectedIndex, children: pages), - bottomNavigationBar: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const ActiveCallBar(), - Container( - height: 120, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 2, - ), - ), - ), - child: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - currentIndex: selectedIndex, - selectedItemColor: Theme.of(context).colorScheme.primary, - unselectedItemColor: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - onTap: _onItemTapped, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.chat_bubble_outline), - label: 'Announcements', - ), - BottomNavigationBarItem( - icon: Icon(Icons.check_box_outlined), - label: 'Assignments', - ), - BottomNavigationBarItem( - icon: Icon(Icons.menu_outlined), - label: 'More', - ), - ], - ), - ), - ], - ), - ); - } -} - diff --git a/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart b/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart deleted file mode 100644 index 486d3333..00000000 --- a/mobile/lib/ui/app/single-class/staff/students/add_student_screen.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/exceptions/student_exception.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/util/validators.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AddStudentScreen extends StatefulWidget { - const AddStudentScreen({super.key}); - - @override - State createState() => _AddStudentScreenState(); -} - -class _AddStudentScreenState extends State { - final formKey = GlobalKey(); - final nameController = TextEditingController(); - final emailController = TextEditingController(); - final passwordController = TextEditingController(); - - bool autoGeneratePassword = false; - bool sendInstructions = false; - bool isSubmitting = false; - bool emailSubmitted = false; - - bool get isFormValid { - if (nameController.text.trim().isEmpty) return false; - if (emailController.text.trim().isEmpty) return false; - if (!autoGeneratePassword && passwordController.text.trim().isEmpty) { - return false; - } - return true; - } - - @override - void initState() { - super.initState(); - emailController.addListener(() { - if (emailSubmitted) { - emailSubmitted = false; - formKey.currentState?.validate(); - } - }); - nameController.addListener(() => setState(() {})); - passwordController.addListener(() => setState(() {})); - } - - Future submitStudent() async { - setState(() => emailSubmitted = true); - if (!formKey.currentState!.validate()) return; - setState(() => isSubmitting = true); - try { - var newStudent = await GetIt.I().addStudent( - nameController.text.trim(), - emailController.text.trim(), - autoGeneratePassword, - autoGeneratePassword ? null : passwordController.text.trim(), - sendInstructions, - ); - await GetIt.I().trackEvent( - 'add_student', - parameters: { - 'status': 'success', - 'auto_generated_password': autoGeneratePassword.toString(), - 'sent_instructions': sendInstructions.toString(), - }, - ); - - if (!mounted) return; - context.read().addStudent(newStudent); - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Student added successfully", - onComplete: (_) { - if (mounted) Navigator.pop(context); - }, - ); - } on EmailAlreadyExist { - await GetIt.I().trackEvent( - 'add_student', - parameters: {'status': 'failed', 'error': 'student_exists'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "E-mail is already used", - onComplete: (_) {}, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'add_student', - parameters: {'status': 'failed', 'error': "unknown_error"}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "An error has occurred", - onComplete: (_) {}, - ); - } - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - @override - void dispose() { - nameController.dispose(); - emailController.dispose(); - passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'New Student', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 12), - ThemedInputField( - controller: nameController, - labelText: "Name", - textInputAction: TextInputAction.next, - hintText: "Ahmed Elbelehy", - ), - const SizedBox(height: 20), - ThemedInputField( - controller: emailController, - obscureText: false, - labelText: "E-mail", - textInputAction: TextInputAction.next, - hintText: "ahmedElbelehy@twoaxis.org", - validator: (input) => - validateEmail(input, isSubmitted: emailSubmitted), - ), - const SizedBox(height: 20), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Autogenerate a password", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: autoGeneratePassword, - onChanged: (value) { - setState(() { - autoGeneratePassword = value!; - if (autoGeneratePassword) sendInstructions = true; - }); - }, - ), - if (!autoGeneratePassword) ...[ - ThemedInputField( - controller: passwordController, - labelText: "Password", - textInputAction: TextInputAction.done, - hintText: "•••••••••••••••••", - ), - const SizedBox(height: 10), - ], - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Send user instructions on how to login.", - style: TextStyle( - color: autoGeneratePassword - ? Theme.of( - context, - ).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: sendInstructions, - onChanged: autoGeneratePassword - ? null - : (v) => setState(() => sendInstructions = v!), - ), - ], - ), - ), - ), - PrimaryButton( - text: isSubmitting ? "Adding..." : "Add Student", - onPressed: submitStudent, - enabled: !isSubmitting && isFormValid, - ), - SizedBox(height: 30), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart b/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart deleted file mode 100644 index 7bcedc28..00000000 --- a/mobile/lib/ui/app/single-class/staff/students/bulk_import_students_screen.dart +++ /dev/null @@ -1,442 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'package:csv/csv.dart'; -import 'package:excel/excel.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/exceptions/student_exception.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; -import 'package:path/path.dart' as p; - -class BulkImportStudentsScreen extends StatefulWidget { - const BulkImportStudentsScreen({super.key}); - - @override - State createState() => - _BulkImportStudentsScreenState(); -} - -class _BulkImportStudentsScreenState extends State { - List> fileData = []; - String? filePath; - List headers = []; - - String? selectedNameHeader; - String? selectedEmailHeader; - String? selectedPasswordHeader; - bool autoGeneratePassword = true; - bool sendInstructions = true; - bool _loading = false; - - Future _pickCsvFile() async { - FilePickerResult? result = await FilePicker.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ['csv', 'xls', 'xlsx'], - ); - - List> fields = []; - - if (result != null) { - filePath = result.files.single.path; - String extension = p.extension(filePath!); - File file = File(filePath!); - - if (!mounted) return; - - if (file.lengthSync() > 0) { - if (extension == '.csv') { - Stream> input = File(filePath!).openRead(); - var csvString = await input.transform(utf8.decoder).join(); - fields = csv.decode(csvString); - } else if (extension == '.xls' || extension == '.xlsx') { - var bytes = file.readAsBytesSync(); - var excel = Excel.decodeBytes(bytes); - - for (var table in excel.tables.keys) { - for (var row in excel.tables[table]!.rows) { - List filteredRow = row - .where((cell) => cell != null) - .map((cell) => cell!.value.toString()) - .toList(); - if (filteredRow.isNotEmpty) { - fields.add(filteredRow); - } - } - break; - } - } - } - } - - if (fields.isEmpty || fields.length == 1) { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'The selected file has no data.', - onComplete: (_) {}, - ); - return; - } - - setState(() { - fileData = fields; - headers = fields.first.map((e) => e.toString()).toList(); - }); - } - - Future bulkImportStudents() async { - if (selectedNameHeader == null || selectedEmailHeader == null) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Please select both Name and Email columns.', - onComplete: (_) {}, - ); - return; - } - if (!autoGeneratePassword && selectedPasswordHeader == null) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Please select the Password column.', - onComplete: (_) {}, - ); - return; - } - - setState(() { - _loading = true; - }); - - try { - int nameIndex = headers.indexOf(selectedNameHeader!); - int emailIndex = headers.indexOf(selectedEmailHeader!); - int? passwordIndex = autoGeneratePassword - ? null - : headers.indexOf(selectedPasswordHeader!); - - List> organizedStudentsList = []; - - for (int i = 1; i < fileData.length; i++) { - var row = fileData[i]; - - if (row.isEmpty || - row.length <= nameIndex || - row.length <= emailIndex) { - continue; - } - - Map studentData = { - 'name': row[nameIndex].toString().trim(), - 'email': row[emailIndex].toString().trim(), - }; - - if (!autoGeneratePassword && - passwordIndex != null && - row.length > passwordIndex) { - studentData['password'] = row[passwordIndex].toString().trim(); - } - - organizedStudentsList.add(studentData); - } - - List newStudentsIds = await GetIt.I() - .bulkImportStudents( - organizedStudentsList, - autoGeneratePassword, - sendInstructions, - ); - - List newStudentsList = []; - for (int i = 0; i < newStudentsIds.length; i++) { - newStudentsList.add( - Student( - id: newStudentsIds[i], - name: organizedStudentsList[i]['name'], - email: organizedStudentsList[i]['email'], - createdAt: DateTime.now().toIso8601String(), - ), - ); - } - - if (!mounted) return; - context.read().bulkImportStudents(newStudentsList); - - showTimedModalNotice( - context: context, - text: 'Students were imported successfully.', - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - duration: const Duration(seconds: 2), - onComplete: (_) { - if (mounted) Navigator.pop(context); - }, - ); - await GetIt.I().trackEvent( - 'bulk_import_students', - parameters: {'status': 'success', 'tenant_type': 'single_class'}, - ); - } on EmailAlreadyExist { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Import failed. One or more emails in your file already exist.', - onComplete: (_) {}, - ); - await GetIt.I().trackEvent( - 'bulk_import_students', - parameters: { - 'status': 'failed', - 'error': 'email_exists', - 'tenant_type': 'single_class', - }, - ); - } catch (e) { - if (!mounted) return; - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: 'Import failed. Please recheck your data and try again.', - onComplete: (_) {}, - ); - await GetIt.I().trackEvent( - 'bulk_import_students', - parameters: { - 'status': 'failed', - 'error': 'unknown_error', - 'tenant_type': 'single_class', - }, - ); - } finally { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - 'Import Students', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - Expanded( - child: headers.isEmpty - ? buildUploadView() - : buildHeaderMappingView(), - ), - if (headers.isNotEmpty) - PrimaryButton( - text: _loading ? 'Importing...' : 'Import', - onPressed: _loading ? () {} : bulkImportStudents, - ), - ], - ), - ), - ), - ); - } - - Widget buildUploadView() { - return InkWell( - splashColor: Colors.transparent, - onTap: () async { - await _pickCsvFile(); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Bulk Import Students', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - SizedBox(height: 8), - Text( - 'Upload a CSV or Excel file containing students data', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500), - ), - SizedBox(height: 10), - Icon( - Icons.upload_file_outlined, - size: 92, - color: Theme.of(context).colorScheme.onSurface, - ), - ], - ), - ); - } - - Widget buildHeaderMappingView() { - return SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 25), - Text( - 'Bulk Import Students', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - buildDropdownMenu( - label: 'Name Column', - onSelected: (value) { - selectedNameHeader = value; - }, - ), - buildDropdownMenu( - label: 'Email Column', - onSelected: (value) { - selectedEmailHeader = value; - }, - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Autogenerate a password", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: autoGeneratePassword, - onChanged: (value) { - setState(() { - autoGeneratePassword = value!; - if (autoGeneratePassword) sendInstructions = true; - }); - }, - ), - if (!autoGeneratePassword) - buildDropdownMenu( - label: 'Password Column', - onSelected: (value) { - selectedPasswordHeader = value; - }, - ), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - activeColor: Theme.of(context).colorScheme.primary, - checkColor: Theme.of(context).colorScheme.surface, - title: Text( - "Send user instructions on how to login.", - style: TextStyle( - color: autoGeneratePassword - ? Theme.of(context).colorScheme.onSurfaceVariant - : Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - ), - value: sendInstructions, - onChanged: autoGeneratePassword - ? null - : (v) => setState(() => sendInstructions = v!), - ), - ], - ), - ); - } - - Widget buildDropdownMenu({ - required String label, - required ValueChanged onSelected, - }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text( - label, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - SizedBox(height: 8), - DropdownMenu( - onSelected: onSelected, - inputDecorationTheme: InputDecorationTheme( - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.outline, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.outline, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, - ), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide( - width: 2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - hintText: '-- Select Column --', - expandedInsets: EdgeInsets.zero, - dropdownMenuEntries: headers.map((header) { - return DropdownMenuEntry(value: header, label: header); - }).toList(), - ), - ], - ), - ); - } -} \ No newline at end of file diff --git a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart b/mobile/lib/ui/app/single-class/staff/students/student_widget.dart deleted file mode 100644 index 562477f8..00000000 --- a/mobile/lib/ui/app/single-class/staff/students/student_widget.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/profile/profile_info_screen.dart'; - -class StudentWidget extends StatelessWidget { - final Student student; - final VoidCallback onDelete; - final String classId; - - const StudentWidget({ - super.key, - required this.student, - required this.onDelete, - this.classId = 'single', - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileInfoScreen( - studentId: student.id, - onDelete: onDelete, - classId: classId, - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(25), - child: Image.asset( - "assets/images/profile_picture.png", - width: 50, - height: 50, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Text( - student.name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 20, - fontWeight: FontWeight.w400, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/students/students_header.dart b/mobile/lib/ui/app/single-class/staff/students/students_header.dart deleted file mode 100644 index 37185430..00000000 --- a/mobile/lib/ui/app/single-class/staff/students/students_header.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/ui/app/organization/staff/students/bulk_import_students_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/students/add_student_screen.dart'; -import 'package:yaspo_mobile/util/plan_util.dart'; - -class StudentsHeader extends StatelessWidget { - const StudentsHeader({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - var user = context.watch().state; - var studentsCount = context.watch().state.length; - var limitReached = PlanUtil.hasReachedStudentLimit(user?.tenant?.plan, studentsCount); - - return Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'Students', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - IconButton( - icon: Icon( - Icons.upload_file_outlined, - size: 32, - color: limitReached ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5) : Theme.of(context).colorScheme.onSurface, - ), - onPressed: limitReached ? null : () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => BulkImportStudentsScreen()), - ); - }, - ), - IconButton( - icon: Icon( - Icons.add, - size: 32, - color: limitReached ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5) : Theme.of(context).colorScheme.onSurface, - ), - onPressed: limitReached ? null : () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => AddStudentScreen()), - ); - }, - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/staff/students/students_screen.dart b/mobile/lib/ui/app/single-class/staff/students/students_screen.dart deleted file mode 100644 index ec390452..00000000 --- a/mobile/lib/ui/app/single-class/staff/students/students_screen.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/student/student_cubit.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/students/student_widget.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/students/students_header.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class StudentsScreen extends StatefulWidget { - const StudentsScreen({super.key}); - - @override - State createState() => _StudentsScreenState(); -} - -class _StudentsScreenState extends State { - bool loading = false; - bool error = false; - - @override - void initState() { - super.initState(); - fetchStudents(); - } - - Future fetchStudents() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - await GetIt.I().trackEvent( - 'fetch_students', - parameters: {'status': 'success'}, - ); - var data = await GetIt.I().getStudents(); - if (mounted) { - context.read().setStudents(data); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_students', - parameters: {'status': 'failed'}, - ); - if (mounted) setState(() => error = true); - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - StudentsHeader(), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchStudents, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent() { - var students = context.watch().state; - students.sort( - (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), - ); - - if (loading && students.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const MemberSkeleton(), - ); - } - - if ((error || students.isEmpty) && !loading) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - error - ? 'Failed to load students. Please try again.' - : 'No students available.', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: students.length, - separatorBuilder: (context, index) => - Divider(color: Theme.of(context).colorScheme.outline, height: 2), - itemBuilder: (context, index) { - var student = students[index]; - return StudentWidget( - student: student, - onDelete: () { - context.read().deleteStudent(student.id); - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Student deleted successfully", - onComplete: (_) { - Navigator.of(context).pop(); - }, - ); - }, - ); - }, - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/announcement/announcement_screen.dart b/mobile/lib/ui/app/single-class/student/announcement/announcement_screen.dart deleted file mode 100644 index bc282299..00000000 --- a/mobile/lib/ui/app/single-class/student/announcement/announcement_screen.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/announcement_service.dart'; -import 'package:yaspo_mobile/model/announcement/announcement.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/announcement/announcement_widget.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AnnouncementScreen extends StatefulWidget { - const AnnouncementScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AnnouncementScreenState(); -} - -class _AnnouncementScreenState extends State { - bool loading = false; - bool error = false; - List announcements = []; - - @override - void initState() { - super.initState(); - fetchAnnouncements(); - } - - Future fetchAnnouncements() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getAnnouncements( - widget.classId, - ); - await GetIt.I().trackEvent( - 'fetch_announcements', - parameters: {'type': 'student', 'status': 'success'}, - ); - if (mounted) { - setState(() { - announcements = data; - }); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_announcements', - parameters: {'type': 'student', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Announcements', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 28), - Expanded( - child: RefreshIndicator( - onRefresh: fetchAnnouncements, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ); - } - - Widget _buildContent() { - if (loading && announcements.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const AnnouncementSkeleton(), - ); - } - - if (error && announcements.isEmpty) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "Failed to load announcements.\nPlease try again later.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ); - }, - ); - } - - if (announcements.isEmpty) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "No announcements available.", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ); - }, - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: announcements.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - var announcement = announcements[index]; - return AnnouncementWidget(announcement: announcement); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/announcement/announcement_widget.dart b/mobile/lib/ui/app/single-class/student/announcement/announcement_widget.dart deleted file mode 100644 index 7e7833bd..00000000 --- a/mobile/lib/ui/app/single-class/student/announcement/announcement_widget.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/announcement/announcement.dart'; -import 'package:yaspo_mobile/util/time_format.dart'; - -class AnnouncementWidget extends StatelessWidget { - const AnnouncementWidget({super.key, required this.announcement}); - - final Announcement announcement; - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 40, - height: 40, - fit: BoxFit.contain, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - announcement.authorName, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Text( - formatTimeAgo(createdAt: announcement.createdAt), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 10), - Text( - announcement.content, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/assignment/assignment_card.dart b/mobile/lib/ui/app/single-class/student/assignment/assignment_card.dart deleted file mode 100644 index a93b5ef6..00000000 --- a/mobile/lib/ui/app/single-class/student/assignment/assignment_card.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/cubit/assignment/assignment_cubit.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/assignment/assignment_details_screen.dart'; - -class AssignmentCard extends StatelessWidget { - final Assignment assignment; - final String classId; - - const AssignmentCard({super.key, required this.assignment, this.classId = 'single'}); - - DateTime _parseDueDate() { -// ... existing logic ... - try { - return DateTime.parse(assignment.dueDate); - } catch (_) { - try { - return DateFormat('dd/MM/yyyy').parse(assignment.dueDate.trim()); - } catch (e) { - return DateTime.now(); - } - } - } - - String _calculateStatus() { -// ... existing logic ... - if (assignment.submitted) { - return 'Turned In'; - } - - var due = _parseDueDate(); - var now = DateTime.now(); - var diff = due.difference(now).inDays; - - if (now.isAfter(due)) { - return 'Overdue'; - } - - if (diff == 0) { - return 'Due Today'; - } - - if (diff < 7) { - return 'Due in $diff ${diff == 1 ? 'day' : 'days'}'; - } - var weeks = (diff / 7).ceil(); - return 'Due in $weeks ${weeks == 1 ? 'week' : 'weeks'}'; - } - - Color _getStatusColor(BuildContext context) { -// ... existing logic ... - if (assignment.submitted) { - return Theme.of(context).colorScheme.tertiary; - } - - var due = _parseDueDate(); - var now = DateTime.now(); - - if (now.isAfter(due)) { - return Theme.of(context).colorScheme.error; - } - - var diff = due.difference(now).inDays; - if (diff < 3) return Theme.of(context).colorScheme.error; - if (diff < 7) return Theme.of(context).colorScheme.primary; - return Theme.of(context).colorScheme.onSurface; - } - - @override - Widget build(BuildContext context) { - var statusText = _calculateStatus(); - var statusColor = _getStatusColor(context); - - return InkWell( - onTap: () { - var cubit = context.read(); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => BlocProvider.value( - value: cubit, - child: AssignmentDetailsScreen(assignment: assignment, classId: classId), - ), - ), - ); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - padding: const EdgeInsets.all(12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - assignment.title, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - statusText, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: statusColor, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/assignment/assignment_details_screen.dart b/mobile/lib/ui/app/single-class/student/assignment/assignment_details_screen.dart deleted file mode 100644 index 631f0234..00000000 --- a/mobile/lib/ui/app/single-class/student/assignment/assignment_details_screen.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/cubit/assignment/assignment_cubit.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AssignmentDetailsScreen extends StatefulWidget { - final Assignment assignment; - final String classId; - - const AssignmentDetailsScreen({super.key, required this.assignment, this.classId = 'single'}); - - @override - State createState() => - _AssignmentDetailsScreenState(); -} - -class _AssignmentDetailsScreenState extends State { - PlatformFile? _selectedFile; - bool isSubmitting = false; - double progress = 0.0; - - Future _pickFile() async { - FilePickerResult? result = await FilePicker.pickFiles( - type: FileType.custom, - allowedExtensions: ['pdf', 'zip'], - ); - - if (result != null) { - setState(() { - _selectedFile = result.files.first; - }); - } - } - - Future _submit() async { - if (_selectedFile != null && _selectedFile!.path != null) { - setState(() { - isSubmitting = true; - progress = 0.0; - }); - - String mimeType = 'application/octet-stream'; - if (_selectedFile!.extension == 'pdf') { - mimeType = 'application/pdf'; - } else if (_selectedFile!.extension == 'zip') { - mimeType = 'application/zip'; - } - - try { - await GetIt.I().submitAssignment( - widget.assignment.id, - _selectedFile!.path!, - _selectedFile!.size, - mimeType, - (sent, total) { - if (mounted) { - setState(() { - progress = sent / total; - }); - } - }, - widget.classId, - ); - await GetIt.I().trackEvent( - 'submit_assignment', - parameters: {'status': 'success'}, - ); - - // Update local list in Cubit - if (mounted) { - context.read().updateAssignment( - Assignment( - id: widget.assignment.id, - title: widget.assignment.title, - description: widget.assignment.description, - author: widget.assignment.author, - maxGrade: widget.assignment.maxGrade, - dueDate: widget.assignment.dueDate, - submitted: true, - ), - ); - - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: 'Assignment submitted successfully!', - onComplete: (_) { - if (mounted) { - Navigator.pop(context); - } - }, - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'submit_assignment', - parameters: {'status': 'failed'}, - ); - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.error, - text: e.toString(), - onComplete: (_) {}, - ); - } - } finally { - if (mounted) { - setState(() { - isSubmitting = false; - }); - } - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(widget.assignment.title)), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.assignment.submitted) ...[ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.check_circle, - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, - ), - const SizedBox(width: 12), - Text( - 'Turned In', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onTertiaryContainer, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - ], - - Text( - 'Due Date', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - Text(widget.assignment.dueDate), - const SizedBox(height: 16), - - Text( - 'Max Grade', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - Text('${widget.assignment.maxGrade}'), - const SizedBox(height: 16), - - Text( - 'Description', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 4), - Expanded( - child: SingleChildScrollView( - child: Text( - widget.assignment.description, - style: Theme.of(context).textTheme.bodyLarge, - ), - ), - ), - - if (_selectedFile != null) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - Icons.insert_drive_file, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _selectedFile!.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontWeight: FontWeight.bold), - ), - Text( - '${(_selectedFile!.size / 1024).toStringAsFixed(1)} KB', - ), - ], - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: isSubmitting - ? null - : () { - setState(() { - _selectedFile = null; - }); - }, - ), - ], - ), - ), - ], - - const SizedBox(height: 24), - - if (isSubmitting) ...[ - LinearProgressIndicator(value: progress), - const SizedBox(height: 8), - Center(child: Text('${(progress * 100).toStringAsFixed(1)}%')), - const SizedBox(height: 24), - ] else if (!widget.assignment.submitted) ...[ - Center( - child: PrimaryButton( - text: _selectedFile == null - ? 'Select File' - : 'Submit Assignment', - onPressed: () { - if (_selectedFile == null) { - _pickFile(); - } else { - _submit(); - } - }, - ), - ), - ], - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/assignment/assignment_screen.dart b/mobile/lib/ui/app/single-class/student/assignment/assignment_screen.dart deleted file mode 100644 index 1ba546e7..00000000 --- a/mobile/lib/ui/app/single-class/student/assignment/assignment_screen.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/assignment_service.dart'; -import 'package:yaspo_mobile/cubit/assignment/assignment_cubit.dart'; -import 'package:yaspo_mobile/model/assignment/assignment.dart'; -import 'package:yaspo_mobile/theme/color_scheme.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/assignment/assignment_card.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/assignment/student_assignment_header.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class AssignmentScreen extends StatefulWidget { - const AssignmentScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _AssignmentScreenState(); -} - -class _AssignmentScreenState extends State { - bool loading = false; - bool error = false; - late AssignmentCubit _assignmentCubit; - - @override - void initState() { - super.initState(); - _assignmentCubit = AssignmentCubit(); - fetchAssignments(); - } - - @override - void dispose() { - _assignmentCubit.close(); - super.dispose(); - } - - Future fetchAssignments() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getAssignment(widget.classId); - await GetIt.I().trackEvent( - 'fetch_assignments', - parameters: {'type': 'student', 'status': 'success'}, - ); - if (mounted) { - _assignmentCubit.setAssignments(data); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_assignments', - parameters: {'type': 'student', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _assignmentCubit, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - StudentAssignmentHeader(classId: widget.classId), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchAssignments, - color: colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildContent() { - return BlocBuilder>( - builder: (context, assignments) { - if (loading && assignments.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const AssignmentCardSkeleton(), - ); - } - - if (error && assignments.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "Failed to load assignments.\nPlease try again later.", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - if (assignments.isEmpty) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - "No assignments yet.", - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } - - return ListView.separated( - itemCount: assignments.length, - separatorBuilder: (context, index) => const SizedBox(height: 16), - itemBuilder: (context, index) { - return AssignmentCard(assignment: assignments[index], classId: widget.classId); - }, - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/assignment/student_assignment_header.dart b/mobile/lib/ui/app/single-class/student/assignment/student_assignment_header.dart deleted file mode 100644 index d0e51f20..00000000 --- a/mobile/lib/ui/app/single-class/student/assignment/student_assignment_header.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; - -class StudentAssignmentHeader extends StatelessWidget { - const StudentAssignmentHeader({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Assignments', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/grade_sheets/student_grade_sheets_screen.dart b/mobile/lib/ui/app/single-class/student/grade_sheets/student_grade_sheets_screen.dart deleted file mode 100644 index da1f3998..00000000 --- a/mobile/lib/ui/app/single-class/student/grade_sheets/student_grade_sheets_screen.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/grade_sheet_service.dart'; -import 'package:yaspo_mobile/cubit/grade_sheet/grade_sheet_cubit.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class StudentGradeSheetsScreen extends StatefulWidget { - const StudentGradeSheetsScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => - _StudentGradeSheetsScreenState(); -} - -class _StudentGradeSheetsScreenState extends State { - bool loading = false; - bool error = false; - - @override - void initState() { - super.initState(); - fetchGradeSheets(); - } - - Future fetchGradeSheets() async { - if (!mounted) return; - setState(() { - loading = true; - error = false; - }); - - try { - await GetIt.I().trackEvent( - 'fetch_grade_sheets', - parameters: {'type': 'student', 'status': 'success'}, - ); - var sheets = await GetIt.I().getGradeSheets(widget.classId); - if (mounted) { - context.read().setGradeSheets(sheets); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_grade_sheets', - parameters: {'type': 'student', 'status': 'failed'}, - ); - if (mounted) setState(() => error = true); - } finally { - if (mounted) setState(() => loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: Column( - children: [ - _buildHeader(context), - const SizedBox(height: 16), - Expanded( - child: RefreshIndicator( - onRefresh: fetchGradeSheets, - color: Theme.of(context).colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Row( - children: [ - IconButton( - onPressed: () { - Navigator.pop(context); - }, - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - size: 25, - ), - ), - Text( - 'My Grades', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ); - } - - Widget _buildContent() { - var gradeSheets = context.watch().state; - - if (loading && gradeSheets.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 20), - itemBuilder: (context, index) => const SheetItemSkeleton(), - ); - } - - if ((error || gradeSheets.isEmpty) && !loading) { - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - error - ? 'Failed to load grade sheets. Please try again.' - : 'No grades available.', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } - - return ListView.separated( - padding: const EdgeInsets.only(bottom: 16), - physics: const AlwaysScrollableScrollPhysics(), - itemCount: gradeSheets.length, - separatorBuilder: (_, _) => const SizedBox(height: 20), - itemBuilder: (context, index) { - var sheet = gradeSheets[index]; - return Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Text( - sheet.name, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - Text( - sheet.grade ?? "No Grade", - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: sheet.grade != null - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/more/more_screen.dart b/mobile/lib/ui/app/single-class/student/more/more_screen.dart deleted file mode 100644 index db43b88c..00000000 --- a/mobile/lib/ui/app/single-class/student/more/more_screen.dart +++ /dev/null @@ -1,353 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/auth_service.dart'; -import 'package:yaspo_mobile/abstract/notification_service.dart'; -import 'package:yaspo_mobile/abstract/token_storage_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/meetings/meetings_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/grade_sheets/student_grade_sheets_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/profile/profile_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/quiz/student_quiz_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/more/row_item.dart'; -import 'package:yaspo_mobile/ui/onboarding/onboarding/onboarding_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; -import 'package:yaspo_mobile/values/version.dart'; - -class MoreScreen extends StatelessWidget { - const MoreScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - Widget build(BuildContext context) { - Future logout() async { - String? fcmToken = await GetIt.I().getFcmToken(); - - String? refreshToken = await GetIt.I() - .getRefreshToken(); - await GetIt.I().logout(refreshToken!, fcmToken!); - await GetIt.I().clearTokens(); - await GetIt.I().trackEvent( - 'user_logout', - parameters: {'type': 'student', 'status': 'success'}, - ); - await GetIt.I().clearUser(); - - if (context.mounted) { - context.read().setUserModel(null); - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const OnboardingScreen()), - (route) => false, - ); - } - } - - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, user) { - if (user == null || - user.tenant == null || - user.tenant!.type == TenantType.ORGANIZATION) { - return const SizedBox.shrink(); - } - return Text( - user.tenant!.name, - style: TextStyle( - fontSize: 24, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ); - }, - ), - SizedBox(height: 24), - InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const ProfileScreen(), - ), - ); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - ClipRRect( - child: Image.asset( - "assets/images/profile_picture.png", - width: 50, - height: 50, - fit: BoxFit.contain, - ), - ), - SizedBox(width: 15), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BlocBuilder( - builder: (context, user) { - if (user == null) { - return Text( - 'You', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ); - } - return Text( - user.name, - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ); - }, - ), - const SizedBox(height: 1), - Text( - 'Account Settings', - style: TextStyle( - fontWeight: FontWeight.w400, - fontSize: 12, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios_outlined, - size: 24, - color: Theme.of(context).colorScheme.onSurface, - ), - ], - ), - ), - ), - ), - const SizedBox(height: 25), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - children: [ - RowItem( - icon: Icons.list, - title: 'Quizzes', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => StudentQuizScreen(classId: classId), - ), - ); - }, - ), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 2, - ), - RowItem( - icon: Icons.file_copy_outlined, - title: 'Grades', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => StudentGradeSheetsScreen(classId: classId), - ), - ); - }, - ), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 2, - ), - RowItem( - icon: Icons.video_call, - title: 'Meetings', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => MeetingsScreen(classId: classId), - ), - ); - }, - ), - ], - ), - ), - SizedBox(height: 24), - InkWell( - onTap: logout, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - Icon( - Icons.logout, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'Sign out', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 20), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.build, - size: 20, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 8), - Text( - 'App Version', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ], - ), - Text( - VERSION, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - Divider( - color: Theme.of(context).colorScheme.outline, - height: 2, - ), - Padding( - padding: EdgeInsets.symmetric( - vertical: 16, - horizontal: 20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.build, - size: 20, - color: Theme.of(context).colorScheme.onSurface, - ), - const SizedBox(width: 8), - Text( - 'Build Number', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ], - ), - Text( - BUILD_NUMBER.toString(), - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/more/row_item.dart b/mobile/lib/ui/app/single-class/student/more/row_item.dart deleted file mode 100644 index bf46a08a..00000000 --- a/mobile/lib/ui/app/single-class/student/more/row_item.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; - -class RowItem extends StatelessWidget { - const RowItem({ - super.key, - required this.icon, - required this.title, - required this.onTap, - }); - - final IconData icon; - final String title; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: Row( - children: [ - Icon(icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 10), - Text( - title, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/profile/profile_screen.dart b/mobile/lib/ui/app/single-class/student/profile/profile_screen.dart deleted file mode 100644 index c5e9e47c..00000000 --- a/mobile/lib/ui/app/single-class/student/profile/profile_screen.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:yaspo_mobile/abstract/student_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/model/student/student.dart'; -import 'package:yaspo_mobile/model/user/user.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class ProfileScreen extends StatefulWidget { - const ProfileScreen({super.key}); - - @override - State createState() => _ProfileScreenState(); -} - -class _ProfileScreenState extends State { - Student? _student; - bool _loading = true; - - @override - void initState() { - super.initState(); - _fetchStudentInfo(); - } - - Future _fetchStudentInfo() async { - var user = context.read().state; - if (user == null) return; - - try { - var student = await GetIt.I().getStudentById(user.id); - if (mounted) { - setState(() { - _student = student; - _loading = false; - }); - } - } catch (_) { - if (mounted) setState(() => _loading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: BlocBuilder( - builder: (context, user) { - if (user == null || _loading) { - return const ProfileSkeleton(); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: Icon( - Icons.arrow_back, - size: 25, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const Spacer(), - ], - ), - const SizedBox(height: 10), - ClipRRect( - borderRadius: BorderRadius.circular(50), - child: Image.asset( - "assets/images/profile_picture.png", - width: 100, - height: 100, - fit: BoxFit.contain, - ), - ), - const SizedBox(height: 12), - Text( - user.name, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Text( - user.email, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 6), - Text( - "Added ${DateFormat('dd/MM/yyyy').format(_student?.createdAt != null && _student!.createdAt.isNotEmpty ? DateTime.parse(_student!.createdAt) : DateTime.now())}", - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 14, - fontWeight: FontWeight.w400, - ), - ), - const SizedBox(height: 24), - ], - ); - }, - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart deleted file mode 100644 index a1995c62..00000000 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_card.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; - -class QuizCard extends StatefulWidget { - final Quiz quiz; - final VoidCallback onTap; - - const QuizCard({super.key, required this.quiz, required this.onTap}); - - @override - State createState() => _QuizCardState(); -} - -class _QuizCardState extends State { - Timer? _timer; - late DateTime _now; - - @override - void initState() { - super.initState(); - _now = DateTime.now(); - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (mounted) { - setState(() { - _now = DateTime.now(); - }); - } - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - bool get _isStarted => - widget.quiz.startsAt == null || _now.isAfter(widget.quiz.startsAt!); - bool get _isEnded => - widget.quiz.endsAt != null && _now.isAfter(widget.quiz.endsAt!); - bool get _canTake => - _isStarted && !_isEnded && !(widget.quiz.hasTaken ?? false); - - String _getStatusText() { - if (widget.quiz.hasTaken ?? false) { - return 'Completed'; - } - - if (_isEnded) { - return 'Missed'; - } - - if (!_isStarted) { - var diff = widget.quiz.startsAt!.difference(_now); - if (diff.inDays > 0) { - return 'Starts in ${diff.inDays} ${diff.inDays == 1 ? 'day' : 'days'}'; - } - if (diff.inHours > 0) { - return 'Starts in ${diff.inHours} ${diff.inHours == 1 ? 'hour' : 'hours'}'; - } - if (diff.inMinutes > 0) { - return 'Starts in ${diff.inMinutes} ${diff.inMinutes == 1 ? 'minute' : 'minutes'}'; - } - return 'Starting soon...'; - } - - var diff = widget.quiz.endsAt!.difference(_now); - if (diff.inDays > 0) { - return 'Ends in ${diff.inDays} ${diff.inDays == 1 ? 'day' : 'days'}'; - } - if (diff.inHours > 0) { - return 'Ends in ${diff.inHours} ${diff.inHours == 1 ? 'hour' : 'hours'}'; - } - return 'Ends in ${diff.inMinutes} ${diff.inMinutes == 1 ? 'minute' : 'minutes'}'; - } - - Color _getStatusColor(BuildContext context) { - if (widget.quiz.hasTaken ?? false) { - return Colors.green; - } - if (_isEnded) { - return Theme.of(context).colorScheme.error; - } - if (!_isStarted) { - return Colors.orange; - } - return Theme.of(context).colorScheme.primary; - } - - @override - Widget build(BuildContext context) { - var statusText = _getStatusText(); - var statusColor = _getStatusColor(context); - - return InkWell( - onTap: _canTake || (widget.quiz.hasTaken ?? false) ? widget.onTap : null, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - color: _canTake || (widget.quiz.hasTaken ?? false) - ? null - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - widget.quiz.title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - if (widget.quiz.hasTaken ?? false) - const Icon(Icons.check_circle, color: Colors.green) - else if (_canTake) - Icon( - Icons.play_circle_outline, - color: Theme.of(context).colorScheme.primary, - ) - else if (!_isStarted) - const Icon(Icons.timer_outlined, color: Colors.orange) - else - Icon( - Icons.lock_outline, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.info_outline, size: 16, color: statusColor), - const SizedBox(width: 4), - Text( - statusText, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: statusColor, - ), - ), - const Spacer(), - Text( - '${widget.quiz.questionsCount ?? 0} Questions', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart deleted file mode 100644 index a047c4bf..00000000 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_result_screen.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; - -class QuizResultScreen extends StatefulWidget { - final String quizId; - final String quizTitle; - final QuizResult? initialResult; - final String classId; - - const QuizResultScreen({ - super.key, - required this.quizId, - required this.quizTitle, - this.initialResult, - this.classId = 'single', - }); - - @override - State createState() => _QuizResultScreenState(); -} - -class _QuizResultScreenState extends State { - late Future _resultFuture; - - @override - void initState() { - super.initState(); - if (widget.initialResult != null) { - _resultFuture = Future.value(widget.initialResult); - } else { - _resultFuture = GetIt.I().getScore(widget.quizId, widget.classId); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Quiz Result')), - body: FutureBuilder( - future: _resultFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - - if (snapshot.hasError) { - return Center( - child: Text( - '''Failed to load results. -${snapshot.error}''', - textAlign: TextAlign.center, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ); - } - - var result = snapshot.data!; - var hasScore = result.score != null; - var percentage = hasScore ? ((result.score! / result.totalPoints) * 100).round() : 0; - - return Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.quizTitle, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 40), - if (hasScore) ...[ - Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 200, - height: 200, - child: CircularProgressIndicator( - value: result.totalPoints > 0 ? result.score! / result.totalPoints : 0, - strokeWidth: 12, - color: _getScoreColor(percentage), - backgroundColor: Theme.of( - context, - ).colorScheme.outline.withValues(alpha: 0.2), - ), - ), - Column( - children: [ - Text( - '$percentage%', - style: const TextStyle( - fontSize: 48, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'Score: ${result.score} / ${result.totalPoints}', - style: TextStyle( - fontSize: 16, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - if (result.needsManualGrading) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: Text( - 'Pending Manual Grading', - style: TextStyle( - fontSize: 14, - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ], - ), - ] else ...[ - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - Icon( - Icons.visibility_off, - size: 64, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 16), - Text( - 'Grades Hidden', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 8), - Text( - 'Your educator has chosen to hide the results for this quiz. Your score will be released later.', - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - const SizedBox(height: 60), - PrimaryButton( - onPressed: () => Navigator.pop(context), - text: 'Back to Quizzes', - ), - ], - ), - ), - ); - }, - ), - ); - } - - Color _getScoreColor(int percentage) { - if (percentage >= 80) return Colors.green; - if (percentage >= 50) return Colors.orange; - return Colors.red; - } -} diff --git a/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart deleted file mode 100644 index d25a412c..00000000 --- a/mobile/lib/ui/app/single-class/student/quiz/quiz_session_screen.dart +++ /dev/null @@ -1,460 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/cubit/quiz/quiz_session_cubit.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/quiz/quiz_result_screen.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; - -class QuizSessionScreen extends StatefulWidget { - final Quiz quiz; - final String classId; - - const QuizSessionScreen({ - super.key, - required this.quiz, - this.classId = 'single', - }); - - @override - State createState() => _QuizSessionScreenState(); -} - -class _QuizSessionScreenState extends State { - late QuizSessionCubit _sessionCubit; - Timer? _timer; - late DateTime _endTime; - Duration _timeLeft = Duration.zero; - bool _initialized = false; - bool _loading = true; - - @override - void initState() { - super.initState(); - _startQuizSession(); - } - - Future _startQuizSession() async { - try { - var quizData = await GetIt.I().startQuiz( - widget.quiz.id, - widget.classId, - ); - if (mounted) { - setState(() { - _sessionCubit = QuizSessionCubit(quizData); - // start_time maps to startsAt in Quiz.fromJson - var sessionStartTime = quizData.startsAt; - var sessionEndTime = quizData.endsAt!; - - if (sessionStartTime != null) { - var localNow = DateTime.now(); - var clockSkew = sessionStartTime.difference(localNow); - _endTime = sessionEndTime.subtract(clockSkew); - } else { - _endTime = sessionEndTime; - } - - _timeLeft = _endTime.difference(DateTime.now()); - _initialized = true; - _loading = false; - }); - _startTimer(); - } - } catch (e) { - if (mounted) { - setState(() => _loading = false); - _showErrorDialog('''Failed to start quiz session. -$e''', forceExit: true); - } - } - } - - void _startTimer() { - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) return; - var diff = _endTime.difference(DateTime.now()); - if (diff.isNegative) { - _timer?.cancel(); - _submitQuiz(); - } else { - setState(() { - _timeLeft = diff; - }); - } - }); - } - - @override - void dispose() { - _timer?.cancel(); - if (_initialized) _sessionCubit.close(); - super.dispose(); - } - - Future _submitQuiz() async { - if (!_initialized || _sessionCubit.state.submitting) return; - - _sessionCubit.setSubmitting(true); - try { - var answers = _sessionCubit.state.answers.entries.map((e) { - if (e.value is String) { - return {'questionId': e.key, 'selectedAnswerIndex': 0, 'writtenAnswer': e.value}; - } else { - return {'questionId': e.key, 'selectedAnswerIndex': e.value, 'writtenAnswer': null}; - } - }).toList(); - - var result = await GetIt.I().submitQuiz( - widget.quiz.id, - answers, - widget.classId, - ); - - await GetIt.I().trackEvent( - 'submit_quiz', - parameters: {'status': 'success'}, - ); - - if (mounted) { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => QuizResultScreen( - quizId: widget.quiz.id, - quizTitle: widget.quiz.title, - initialResult: result, - classId: widget.classId, - ), - ), - ); - } - } catch (e) { - await GetIt.I().trackEvent( - 'submit_quiz', - parameters: {'status': 'failed'}, - ); - if (mounted) { - _sessionCubit.setSubmitting(false); - var is403 = e.toString().contains('403'); - var message = is403 - ? 'Time is up! Your quiz session has ended.' - : 'Failed to submit quiz.\n$e'; - _showErrorDialog(message, forceExit: is403); - } - } - } - - void _showErrorDialog(String message, {bool forceExit = false}) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Error'), - content: Text(message), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); - if (forceExit) Navigator.pop(context); - }, - child: const Text('OK'), - ), - ], - ), - ); - } - - String _formatDuration(Duration d) { - String twoDigits(int n) => n.toString().padLeft(2, "0"); - String twoDigitMinutes = twoDigits(d.inMinutes.remainder(60)); - String twoDigitSeconds = twoDigits(d.inSeconds.remainder(60)); - if (d.inHours > 0) { - return "${twoDigits(d.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; - } - return "$twoDigitMinutes:$twoDigitSeconds"; - } - - @override - Widget build(BuildContext context) { - if (_loading) { - return Scaffold( - appBar: AppBar(title: Text(widget.quiz.title)), - body: const Center(child: CircularProgressIndicator()), - ); - } - - if (!_initialized) return const SizedBox.shrink(); - - return BlocProvider.value( - value: _sessionCubit, - child: PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - var shouldPop = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Discard Quiz?'), - content: const Text( - 'Leaving now will not submit your answers. Are you sure?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - child: Text( - 'Leave', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - ), - ], - ), - ); - if (shouldPop ?? false) { - if (context.mounted) Navigator.pop(context); - } - }, - child: Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.quiz.title, style: const TextStyle(fontSize: 16)), - Text( - _formatDuration(_timeLeft), - style: TextStyle( - fontSize: 14, - color: _timeLeft.inMinutes < 1 - ? Colors.red - : Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - actions: [ - IconButton( - icon: const Icon(Icons.grid_view), - onPressed: _showQuestionNavigator, - ), - ], - ), - body: BlocBuilder( - builder: (context, state) { - var question = state.quiz.questions![state.currentQuestionIndex]; - return Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Question ${state.currentQuestionIndex + 1} / ${state.quiz.questions!.length}', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - question.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 32), - Expanded( - child: question.type == 0 - ? ListView.separated( - itemCount: question.answers.length, - separatorBuilder: (context, index) => - const SizedBox(height: 16), - itemBuilder: (context, index) { - var answer = question.answers[index]; - var isSelected = state.answers[question.id] == index; - return InkWell( - onTap: () => _sessionCubit.answerQuestion( - question.id!, - index, - ), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - width: isSelected ? 2 : 1, - ), - color: isSelected - ? Theme.of(context).colorScheme.primary - .withValues(alpha: 0.1) - : null, - ), - child: Row( - children: [ - Text( - String.fromCharCode(65 + index), - style: TextStyle( - fontWeight: FontWeight.bold, - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary - : null, - ), - ), - const SizedBox(width: 12), - Expanded(child: Text(answer.text)), - ], - ), - ), - ); - }, - ) - : TextField( - maxLines: null, - minLines: 5, - decoration: const InputDecoration( - hintText: 'Type your answer here...', - border: OutlineInputBorder(), - ), - controller: TextEditingController( - text: state.answers[question.id] as String? ?? '', - )..selection = TextSelection.collapsed( - offset: (state.answers[question.id] as String? ?? '').length, - ), - onChanged: (val) { - _sessionCubit.answerQuestion(question.id!, val); - }, - ), - ), - const SizedBox(height: 24), - _buildNavigationButtons(state), - ], - ), - ); - }, - ), - ), - ), - ); - } - - Widget _buildNavigationButtons(QuizSessionState state) { - var isLast = state.currentQuestionIndex == state.quiz.questions!.length - 1; - return Row( - children: [ - if (state.currentQuestionIndex > 0) - Expanded( - child: OutlinedButton( - onPressed: _sessionCubit.previousQuestion, - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: const Text('Previous'), - ), - ), - if (state.currentQuestionIndex > 0) const SizedBox(width: 16), - Expanded( - child: PrimaryButton( - text: isLast - ? (state.submitting ? 'Submitting...' : 'Submit') - : 'Next', - enabled: !state.submitting, - onPressed: isLast ? _submitQuiz : _sessionCubit.nextQuestion, - ), - ), - ], - ); - } - - void _showQuestionNavigator() { - showModalBottomSheet( - context: context, - builder: (context) { - return BlocProvider.value( - value: _sessionCubit, - child: BlocBuilder( - builder: (context, state) { - return Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Jump to Question', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 20), - GridView.builder( - shrinkWrap: true, - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - ), - itemCount: state.quiz.questions!.length, - itemBuilder: (context, index) { - var questionId = state.quiz.questions![index].id; - var isAnswered = state.answers.containsKey(questionId); - var isCurrent = state.currentQuestionIndex == index; - return InkWell( - onTap: () { - _sessionCubit.goToQuestion(index); - Navigator.pop(context); - }, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: isCurrent - ? Theme.of(context).colorScheme.primary - : (isAnswered - ? Theme.of( - context, - ).colorScheme.primaryContainer - : Theme.of(context).colorScheme.outline - .withValues(alpha: 0.1)), - ), - alignment: Alignment.center, - child: Text( - '${index + 1}', - style: TextStyle( - fontWeight: FontWeight.bold, - color: isCurrent ? Colors.white : null, - ), - ), - ), - ); - }, - ), - const SizedBox(height: 24), - ], - ), - ); - }, - ), - ); - }, - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart b/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart deleted file mode 100644 index 78e74f75..00000000 --- a/mobile/lib/ui/app/single-class/student/quiz/student_quiz_screen.dart +++ /dev/null @@ -1,244 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/abstract/quiz_service.dart'; -import 'package:yaspo_mobile/cubit/quiz/quiz_cubit.dart'; -import 'package:yaspo_mobile/model/quiz/quiz.dart'; -import 'package:yaspo_mobile/theme/color_scheme.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/quiz/quiz_card.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/quiz/quiz_session_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/quiz/quiz_result_screen.dart'; -import 'package:yaspo_mobile/ui/common/skeleton/skeletons.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class StudentQuizScreen extends StatefulWidget { - const StudentQuizScreen({super.key, this.classId = 'single'}); - final String classId; - - @override - State createState() => _StudentQuizScreenState(); -} - -class _StudentQuizScreenState extends State { - bool loading = false; - bool error = false; - late QuizCubit _quizCubit; - - @override - void initState() { - super.initState(); - _quizCubit = QuizCubit(); - fetchQuizzes(); - } - - @override - void dispose() { - _quizCubit.close(); - super.dispose(); - } - - Future fetchQuizzes() async { - if (!mounted) return; - - setState(() { - loading = true; - error = false; - }); - - try { - var data = await GetIt.I().getQuizzes(widget.classId); - await GetIt.I().trackEvent( - 'fetch_quizzes', - parameters: {'type': 'student', 'status': 'success'}, - ); - if (mounted) { - _quizCubit.setQuizzes(data.where((q) => q.isPublished).toList()); - } - } catch (_) { - await GetIt.I().trackEvent( - 'fetch_quizzes', - parameters: {'type': 'student', 'status': 'failed'}, - ); - if (mounted) { - setState(() => error = true); - } - } finally { - if (mounted) { - setState(() => loading = false); - } - } - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _quizCubit, - child: Scaffold( - appBar: AppBar(title: const Text('Quizzes')), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: fullscreenSpacing), - child: RefreshIndicator( - onRefresh: fetchQuizzes, - color: colorScheme.primary, - backgroundColor: Theme.of(context).colorScheme.surface, - child: _buildContent(), - ), - ), - ), - ), - ); - } - - Widget _buildContent() { - return BlocBuilder>( - builder: (context, quizzes) { - if (loading && quizzes.isEmpty) { - return ListView.separated( - itemCount: 10, - separatorBuilder: (_, _) => const SizedBox(height: 16), - itemBuilder: (context, index) => const QuizCardSkeleton(), - ); - } - - if (error && quizzes.isEmpty) { - return _buildMessage('''Failed to load quizzes. -Please try again later.'''); - } - - if (quizzes.isEmpty) { - return _buildMessage("No quizzes available."); - } - - var now = DateTime.now(); - var upcomingQuizzes = quizzes - .where( - (q) => - !(q.hasTaken ?? false) && - (q.endsAt == null || q.endsAt!.isAfter(now)), - ) - .toList(); - var completedQuizzes = quizzes - .where((q) => q.hasTaken ?? false) - .toList(); - var missedQuizzes = quizzes - .where( - (q) => - !(q.hasTaken ?? false) && - q.endsAt != null && - q.endsAt!.isBefore(now), - ) - .toList(); - - return ListView( - padding: const EdgeInsets.symmetric(vertical: 16), - children: [ - if (upcomingQuizzes.isNotEmpty) ...[ - const SectionHeader(title: 'Upcoming Quizzes'), - ...upcomingQuizzes.map( - (q) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: QuizCard(quiz: q, onTap: () => _onQuizTap(context, q)), - ), - ), - ], - if (completedQuizzes.isNotEmpty) ...[ - const SectionHeader(title: 'Completed Quizzes'), - ...completedQuizzes.map( - (q) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: QuizCard(quiz: q, onTap: () => _onQuizTap(context, q)), - ), - ), - ], - if (missedQuizzes.isNotEmpty) ...[ - const SectionHeader(title: 'Missed Quizzes'), - ...missedQuizzes.map( - (q) => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: QuizCard( - quiz: q, - onTap: () => - {}, // Disable tapping for missed quizzes if they can't be taken - ), - ), - ), - ], - ], - ); - }, - ); - } - - void _onQuizTap(BuildContext context, Quiz quiz) { - if (quiz.hasTaken ?? false) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => QuizResultScreen( - quizId: quiz.id, - quizTitle: quiz.title, - classId: widget.classId, - ), - ), - ); - } else { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => QuizSessionScreen( - quiz: quiz, - classId: widget.classId, - ), - ), - ).then((_) => fetchQuizzes()); - } - } - - Widget _buildMessage(String message) { - return LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: Center( - child: Text( - message, - textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ); - }, - ); - } -} - -class SectionHeader extends StatelessWidget { - final String title; - const SectionHeader({super.key, required this.title}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 12, top: 8), - child: Text( - title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} diff --git a/mobile/lib/ui/app/single-class/student/student_app_host.dart b/mobile/lib/ui/app/single-class/student/student_app_host.dart deleted file mode 100644 index 0a17b865..00000000 --- a/mobile/lib/ui/app/single-class/student/student_app_host.dart +++ /dev/null @@ -1,87 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/announcement/announcement_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/assignment/assignment_screen.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/more/more_screen.dart'; - -class StudentAppHost extends StatefulWidget { - const StudentAppHost({super.key, this.classId = 'single', this.className}); - - final String classId; - final String? className; - - @override - State createState() => _App(); -} - -class _App extends State { - int selectedIndex = 0; - - late final List pages; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - pages = [ - AnnouncementScreen(classId: widget.classId), - AssignmentScreen(classId: widget.classId), - MoreScreen(classId: widget.classId), - ]; - } - - void _onItemTapped(int index) { - if (index == selectedIndex) return; - setState(() { - selectedIndex = index; - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: widget.className != null - ? AppBar( - title: Text(widget.className!), - leading: IconButton( - icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), - onPressed: () => Navigator.pop(context), - ), - ) - : null, - body: IndexedStack(index: selectedIndex, children: pages), - bottomNavigationBar: Container( - height: 120, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: Theme.of(context).colorScheme.outline, - width: 2, - ), - ), - ), - child: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - currentIndex: selectedIndex, - selectedItemColor: Theme.of(context).colorScheme.primary, - unselectedItemColor: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - onTap: _onItemTapped, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.chat_bubble_outline), - label: 'Announcements', - ), - BottomNavigationBarItem( - icon: Icon(Icons.check_box_outlined), - label: 'Assignments', - ), - BottomNavigationBarItem( - icon: Icon(Icons.menu_outlined), - label: 'More', - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/ui/onboarding/login/login_screen.dart b/mobile/lib/ui/onboarding/login/login_screen.dart index 84746b1a..051de898 100644 --- a/mobile/lib/ui/onboarding/login/login_screen.dart +++ b/mobile/lib/ui/onboarding/login/login_screen.dart @@ -7,7 +7,7 @@ import 'package:yaspo_mobile/abstract/credential_manager_service.dart'; import 'package:yaspo_mobile/abstract/notification_service.dart'; import 'package:yaspo_mobile/abstract/token_storage_service.dart'; import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; + import 'package:yaspo_mobile/enums/user_type.dart'; import 'package:yaspo_mobile/exceptions/auth_exception.dart'; import 'package:yaspo_mobile/ui/app/organization/staff/staff_organization_host.dart'; @@ -19,7 +19,7 @@ import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; import 'package:yaspo_mobile/ui/onboarding/forget_password/forget_password_screen.dart'; import 'package:yaspo_mobile/ui/onboarding/notification/notification_screen.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_screen.dart'; + import 'package:yaspo_mobile/util/token_util.dart'; import 'package:yaspo_mobile/util/validators.dart'; import 'package:yaspo_mobile/values/spaces.dart'; @@ -115,7 +115,7 @@ class _LoginScreenState extends State if (mounted) { if (hasPermission) { Widget target; - if (tenant.type == TenantType.ORGANIZATION) { + if (true) { target = userType == UserType.STAFF ? const StaffOrganizationHost() : const StudentOrganizationHost(); diff --git a/mobile/lib/ui/onboarding/notification/notification_screen.dart b/mobile/lib/ui/onboarding/notification/notification_screen.dart index 01aba543..e3aa188f 100644 --- a/mobile/lib/ui/onboarding/notification/notification_screen.dart +++ b/mobile/lib/ui/onboarding/notification/notification_screen.dart @@ -3,12 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:yaspo_mobile/abstract/notification_service.dart'; import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/enums/tenant_type.dart'; + import 'package:yaspo_mobile/enums/user_type.dart'; import 'package:yaspo_mobile/ui/app/organization/staff/staff_organization_host.dart'; import 'package:yaspo_mobile/ui/app/organization/student/student_organization_host.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff_app_host.dart'; -import 'package:yaspo_mobile/ui/app/single-class/student/student_app_host.dart'; + + import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/values/spaces.dart'; @@ -19,20 +19,11 @@ class NotificationScreen extends StatelessWidget { var user = context.read().state; if (user == null) return; - var tenantType = user.tenant?.type ?? TenantType.ORGANIZATION; var userType = user.type; - Widget target; - - if (tenantType == TenantType.ORGANIZATION) { - target = userType == UserType.STAFF - ? const StaffOrganizationHost() - : const StudentOrganizationHost(); - } else { - target = userType == UserType.STAFF - ? const StaffAppHost() - : const StudentAppHost(); - } + Widget target = userType == UserType.STAFF + ? const StaffOrganizationHost() + : const StudentOrganizationHost(); Navigator.pushAndRemoveUntil( context, diff --git a/mobile/lib/ui/onboarding/onboarding/onboarding_screen.dart b/mobile/lib/ui/onboarding/onboarding/onboarding_screen.dart index abe189e7..bb244fb2 100644 --- a/mobile/lib/ui/onboarding/onboarding/onboarding_screen.dart +++ b/mobile/lib/ui/onboarding/onboarding/onboarding_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; import 'package:yaspo_mobile/ui/onboarding/login/login_screen.dart'; -import 'package:yaspo_mobile/ui/onboarding/signup/signup_screen.dart'; import 'package:yaspo_mobile/values/spaces.dart'; class OnboardingScreen extends StatelessWidget { @@ -111,18 +110,7 @@ class OnboardingScreen extends StatelessWidget { ), ), ), - const SizedBox(height: 12), - PrimaryButton( - text: "Create a new account", - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => SignupScreen()), - ); - }, - testKey: "signup_button", - ), - const SizedBox(height: 26), + RichText( textAlign: TextAlign.center, text: TextSpan( diff --git a/mobile/lib/ui/onboarding/tenant/tenant_class_name_screen.dart b/mobile/lib/ui/onboarding/tenant/tenant_class_name_screen.dart deleted file mode 100644 index 02aefea3..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_class_name_screen.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/auth_service.dart'; -import 'package:yaspo_mobile/abstract/token_storage_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/exceptions/auth_exception.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_payment_screen.dart'; -import 'package:yaspo_mobile/util/token_util.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class TenantClassNameScreen extends StatefulWidget { - const TenantClassNameScreen({ - super.key, - required this.tenantPlan, - this.isOrganization = false, - }); - - final String tenantPlan; - final bool isOrganization; - - @override - State createState() => _TenantClassNameScreenState(); -} - -class _TenantClassNameScreenState extends State { - final TextEditingController classNameController = TextEditingController(); - bool isButtonEnabled = false; - bool isSubmitting = false; - - @override - void initState() { - super.initState(); - classNameController.addListener(_checkInput); - } - - void _checkInput() { - setState(() { - isButtonEnabled = classNameController.text.trim().isNotEmpty; - }); - } - - @override - void dispose() { - classNameController.dispose(); - super.dispose(); - } - - void createTenant() { - if (!isButtonEnabled || isSubmitting) return; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TenantPaymentScreen( - tenantPlan: widget.tenantPlan, - tenantName: classNameController.text.trim(), - isOrganization: widget.isOrganization, - ), - ), - ); - } - - Future refreshUserToken() async { - var refreshToken = await GetIt.I().getRefreshToken(); - if (refreshToken == null) return; - - try { - var response = await GetIt.I().refresh(refreshToken); - - await GetIt.I().setTokens( - response.accessToken, - response.refreshToken, - ); - - if (mounted) { - context.read().setUserModel( - TokenUtil.decodeToken(response.accessToken), - ); - } - } on InvalidCredentialException { - await GetIt.I().clearTokens(); - if (mounted) context.read().setUserModel(null); - } catch (_) {} - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: Column( - children: [ - Stack( - alignment: Alignment.center, - children: [ - Align( - alignment: Alignment.centerLeft, - child: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back_outlined, size: 25), - ), - ), - Text( - widget.isOrganization ? "Organization name" : "Class name", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - - const SizedBox(height: 32), - ThemedInputField( - controller: classNameController, - labelText: widget.isOrganization - ? "Organization Name" - : "Class Name", - hintText: widget.isOrganization - ? "My Organization" - : "Math 2025", - textInputAction: TextInputAction.done, - ), - - const Spacer(), - PrimaryButton( - text: isSubmitting ? "Submitting..." : "Submit", - onPressed: createTenant, - enabled: isButtonEnabled && !isSubmitting, - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/onboarding/tenant/tenant_details_widget.dart b/mobile/lib/ui/onboarding/tenant/tenant_details_widget.dart deleted file mode 100644 index f2afa8e0..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_details_widget.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; - -class TenantDetailsWidget extends StatelessWidget { - const TenantDetailsWidget({super.key, required this.text}); - - final String text; - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "• ", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - Expanded( - child: Text( - text, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ], - ); - } -} diff --git a/mobile/lib/ui/onboarding/tenant/tenant_payment_screen.dart b/mobile/lib/ui/onboarding/tenant/tenant_payment_screen.dart deleted file mode 100644 index af070ada..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_payment_screen.dart +++ /dev/null @@ -1,291 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/auth_service.dart'; -import 'package:yaspo_mobile/abstract/tenant_service.dart'; -import 'package:yaspo_mobile/abstract/token_storage_service.dart'; -import 'package:yaspo_mobile/cubit/user/user_cubit.dart'; -import 'package:yaspo_mobile/exceptions/auth_exception.dart'; -import 'package:yaspo_mobile/ui/app/organization/staff/staff_organization_host.dart'; -import 'package:yaspo_mobile/ui/app/single-class/staff/staff_app_host.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/common/input/themed_input_field.dart'; -import 'package:yaspo_mobile/ui/common/notice/timed_modal_notice.dart'; -import 'package:yaspo_mobile/ui/onboarding/notification/notification_screen.dart'; -import 'package:yaspo_mobile/abstract/notification_service.dart'; -import 'package:yaspo_mobile/util/payment_formatters.dart'; -import 'package:yaspo_mobile/util/token_util.dart'; - -class TenantPaymentScreen extends StatefulWidget { - const TenantPaymentScreen({ - super.key, - required this.tenantPlan, - required this.tenantName, - this.isOrganization = false, - }); - - final String tenantPlan; - final String tenantName; - final bool isOrganization; - - @override - State createState() => _TenantPaymentScreenState(); -} - -class _TenantPaymentScreenState extends State { - final TextEditingController cardHolderController = TextEditingController(); - final TextEditingController cardNumberController = TextEditingController(); - final TextEditingController expiryController = TextEditingController(); - final TextEditingController cvvController = TextEditingController(); - - bool isButtonEnabled = false; - bool isSubmitting = false; - - @override - void initState() { - super.initState(); - cardHolderController.addListener(_checkInput); - cardNumberController.addListener(_checkInput); - expiryController.addListener(_checkInput); - cvvController.addListener(_checkInput); - } - - void _checkInput() { - setState(() { - isButtonEnabled = cardHolderController.text.trim().isNotEmpty && - cardNumberController.text.trim().length == 16 && - expiryController.text.trim().length == 7 && // MM/YYYY - cvvController.text.trim().length == 3; - }); - } - - @override - void dispose() { - cardHolderController.dispose(); - cardNumberController.dispose(); - expiryController.dispose(); - cvvController.dispose(); - super.dispose(); - } - - void submitPayment() async { - if (!isButtonEnabled || isSubmitting) return; - setState(() => isSubmitting = true); - - var payment = { - "cardHolderName": cardHolderController.text.trim(), - "cardNumber": cardNumberController.text.trim(), - "expiry": expiryController.text.trim(), - "cvv": cvvController.text.trim(), - }; - - try { - if (widget.isOrganization) { - await GetIt.I().createOrganization( - widget.tenantName, - widget.tenantPlan.toLowerCase(), - payment, - ); - } else { - await GetIt.I().createTenant( - widget.tenantName, - widget.tenantPlan.toLowerCase(), - payment, - ); - } - - await refreshUserToken(); - - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.check_circle_outline, - iconColor: Theme.of(context).colorScheme.tertiary, - text: "Subscription active! Welcome aboard.", - onComplete: (_) async { - bool hasPermission = await GetIt.I().hasPermission(); - if (!hasPermission && mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute(builder: (context) => const NotificationScreen()), - (route) => false, - ); - } else { - if (mounted) { - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (context) { - if (widget.isOrganization) { - return const StaffOrganizationHost(); - } - return const StaffAppHost(); - }, - ), - (route) => false, - ); - } - } - }, - ); - } - } catch (e) { - if (mounted) { - showTimedModalNotice( - context: context, - duration: const Duration(seconds: 3), - icon: Icons.error_outline_outlined, - iconColor: Theme.of(context).colorScheme.primary, - text: "Payment failed. Please check your card details.", - onComplete: (_) {}, - ); - } - } finally { - if (mounted) setState(() => isSubmitting = false); - } - } - - Future refreshUserToken() async { - var refreshToken = await GetIt.I().getRefreshToken(); - if (refreshToken == null) return; - - try { - var response = await GetIt.I().refresh(refreshToken); - - await GetIt.I().setTokens( - response.accessToken, - response.refreshToken, - ); - - if (mounted) { - context.read().setUserModel( - TokenUtil.decodeToken(response.accessToken), - ); - } - } on InvalidCredentialException { - await GetIt.I().clearTokens(); - if (mounted) context.read().setUserModel(null); - } catch (_) {} - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: Colors.black), - ), - title: const Text( - "Payment", - style: TextStyle(color: Colors.black, fontWeight: FontWeight.w500), - ), - centerTitle: true, - ), - body: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 20), - Text( - "Finalize Subscription", - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(height: 8), - Text( - "Plan: ${widget.tenantPlan.toUpperCase()}", - style: const TextStyle(fontSize: 16, color: Colors.grey, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 40), - - ThemedInputField( - controller: cardHolderController, - labelText: "Cardholder Name", - hintText: "e.g. Mohamed Eldahtoury", - textInputAction: TextInputAction.next, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z\s]')), - ], - ), - const SizedBox(height: 20), - ThemedInputField( - controller: cardNumberController, - labelText: "Card Number", - hintText: "1234 1234 1234 1234", - keyboardType: TextInputType.number, - textInputAction: TextInputAction.next, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(16), - ], - ), - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: ThemedInputField( - controller: expiryController, - labelText: "Expiry", - hintText: "MM/YYYY", - textInputAction: TextInputAction.next, - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9/]')), - LengthLimitingTextInputFormatter(7), - ExpiryDateFormatter(), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - child: ThemedInputField( - controller: cvvController, - labelText: "CVV", - hintText: "123", - obscureText: true, - keyboardType: TextInputType.number, - textInputAction: TextInputAction.done, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(3), - ], - ), - ), - ], - ), - const SizedBox(height: 48), - PrimaryButton( - text: isSubmitting ? "Processing..." : "Complete Payment", - onPressed: submitPayment, - enabled: isButtonEnabled && !isSubmitting, - ), - const SizedBox(height: 24), - const Text( - "Secure Mock Transaction", - style: TextStyle(color: Colors.grey, fontSize: 12), - ), - const SizedBox(height: 4), - const Text( - "No real charges will be applied.", - style: TextStyle(color: Colors.grey, fontSize: 12), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/onboarding/tenant/tenant_plan_screen.dart b/mobile/lib/ui/onboarding/tenant/tenant_plan_screen.dart deleted file mode 100644 index 949cb7dc..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_plan_screen.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_details_widget.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_plan_widget.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class TenantPlanScreen extends StatelessWidget { - const TenantPlanScreen({super.key, required this.isOrganization}); - - final bool isOrganization; - - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: SingleChildScrollView( - child: Column( - children: [ - Stack( - alignment: Alignment.center, - children: [ - Align( - alignment: Alignment.centerLeft, - child: IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back_outlined, size: 25), - ), - ), - Text( - "Select a plan", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 32), - if (isOrganization) ...[ - TenantPlanWidget( - tenantText: 'Starter', - tenantPrice: '99.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget(text: "150 staff members (educators)"), - const SizedBox(height: 8), - TenantDetailsWidget(text: "1000 students."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Unlimited Classes."), - ], - ), - const SizedBox(height: 28), - TenantPlanWidget( - tenantText: 'Growth', - tenantPrice: '199.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget(text: "300 staff members."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "3000 students."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Unlimited Classes."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "AI assistant teacher."), - ], - ), - const SizedBox(height: 28), - TenantPlanWidget( - tenantText: 'Enterprise', - tenantPrice: '499.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget(text: "More than 3000 students."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Custom plans and pricing."), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Special support."), - ], - ), - ] else ...[ - TenantPlanWidget( - tenantText: 'Basic', - tenantPrice: '9.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget(text: "3 staff members (educators)"), - const SizedBox(height: 8), - TenantDetailsWidget(text: "30 students."), - ], - ), - const SizedBox(height: 28), - TenantPlanWidget( - tenantText: 'Pro', - tenantPrice: '19.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget( - text: "10 staff members. (admin & educators)", - ), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Add up to 100 students."), - ], - ), - const SizedBox(height: 28), - TenantPlanWidget( - tenantText: 'Premium', - tenantPrice: '49.99', - isOrganization: isOrganization, - children: [ - TenantDetailsWidget( - text: "20 staff members. (admin & educators)", - ), - const SizedBox(height: 8), - TenantDetailsWidget(text: "Add up to 300 students."), - ], - ), - ], - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/ui/onboarding/tenant/tenant_plan_widget.dart b/mobile/lib/ui/onboarding/tenant/tenant_plan_widget.dart deleted file mode 100644 index 36146c8d..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_plan_widget.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_class_name_screen.dart'; - -class TenantPlanWidget extends StatelessWidget { - const TenantPlanWidget({ - super.key, - required this.tenantText, - required this.tenantPrice, - required this.isOrganization, - required this.children, - }); - - final String tenantText; - final String tenantPrice; - final bool isOrganization; - final List children; - - @override - Widget build(BuildContext context) { - return Container( - height: 360, - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tenantText, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - RichText( - text: TextSpan( - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - ), - children: [ - TextSpan( - text: '\$$tenantPrice', - style: TextStyle( - fontSize: 64, - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: '/month', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - Column(children: children), - ], - ), - PrimaryButton( - text: "Choose", - onPressed: () async { - await GetIt.I().trackEvent( - 'select_billing_plan', - parameters: {'plan_name': tenantText.toLowerCase()}, - ); - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - TenantClassNameScreen( - tenantPlan: tenantText, - isOrganization: isOrganization, - ), - ), - ); - } - }, - ), - ], - ), - ); - } -} diff --git a/mobile/lib/ui/onboarding/tenant/tenant_screen.dart b/mobile/lib/ui/onboarding/tenant/tenant_screen.dart deleted file mode 100644 index 020d1405..00000000 --- a/mobile/lib/ui/onboarding/tenant/tenant_screen.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; -import 'package:yaspo_mobile/abstract/analytics_service.dart'; -import 'package:yaspo_mobile/ui/common/input/primary_button.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_details_widget.dart'; -import 'package:yaspo_mobile/ui/onboarding/tenant/tenant_plan_screen.dart'; -import 'package:yaspo_mobile/values/spaces.dart'; - -class TenantScreen extends StatelessWidget { - const TenantScreen({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(fullscreenSpacing), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "How do you plan on using YASPO?", - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 32), - Container( - height: 551, - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - SizedBox(height: 10), - Image.asset( - 'assets/images/single-class.png', - width: 92.72, - height: 110, - ), - const SizedBox(height: 16), - Text( - "Single Class", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 35), - Text( - "A simple setup for one class or a small group.", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 16), - TenantDetailsWidget(text: "No multiple classes."), - const SizedBox(height: 8), - TenantDetailsWidget( - text: "Multiple staff/educators.", - ), - const SizedBox(height: 8), - TenantDetailsWidget( - text: "You can upgrade to organization later.", - ), - ], - ), - PrimaryButton( - text: "Create a single class", - onPressed: () async { - await GetIt.I().trackEvent( - 'select_tenant_type', - parameters: {'tenant_type': 'single_class'}, - ); - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TenantPlanScreen(isOrganization: false), - ), - ); - } - }, - ), - ], - ), - ), - const SizedBox(height: 29), - Container( - height: 551, - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - width: 1, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - Image.asset( - 'assets/images/organization.png', - width: 92.72, - height: 110, - ), - const SizedBox(height: 16), - Text( - "Organization", - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 35), - Text( - "Designed for schools, universities, or any large education institute.", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - const SizedBox(height: 16), - TenantDetailsWidget( - text: - "Manage multiple classes within the organization.", - ), - const SizedBox(height: 8), - TenantDetailsWidget( - text: "Multiple staff/educators.", - ), - const SizedBox(height: 8), - TenantDetailsWidget( - text: "Can not downgrade to a single class.", - ), - ], - ), - PrimaryButton( - text: "Create an organization", - onPressed: () async { - await GetIt.I().trackEvent( - 'select_tenant_type', - parameters: {'tenant_type': 'organization'}, - ); - - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TenantPlanScreen(isOrganization: true), - ), - ); - } - }, - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/web/.env b/web/.env index 723041c8..455cac1b 100644 --- a/web/.env +++ b/web/.env @@ -1,2 +1,2 @@ -VITE_API=https://api.staging.yaspo.twoaxis.org -VITE_LIVEKIT_URL=wss://livekit.staging.yaspo.twoaxis.org \ No newline at end of file +VITE_API=http://localhost:5000 +VITE_LIVEKIT_URL=ws://localhost:7880 diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 00000000..06aefc16 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-alpine AS build +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +# Add a default nginx configuration for SPAs +RUN echo 'server { \ + listen 80; \ + location / { \ + root /usr/share/nginx/html; \ + index index.html index.htm; \ + try_files $uri $uri/ /index.html; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/src/App.jsx b/web/src/App.jsx index 77075735..5cedec3c 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -8,25 +8,15 @@ import { } from "react-router-dom"; import PageViewTracker from "./lib/PageViewTracker"; import { decodeToken } from "./util/token.util"; -import Announcements from "./ui/app/single-class/staff/announcements/AnnouncementsPage"; import OrgAnnouncements from "./ui/app/organization/staff/class-ui/announcements/AnnouncementsPage"; -import Quizzes from "./ui/app/single-class/staff/quizzes/QuizzesPage"; import OrgQuizzes from "./ui/app/organization/staff/class-ui/quizzes/QuizzesPage"; -import GradeSheets from "./ui/app/single-class/staff/grade-sheets/GradeSheetsPage"; import OrgGradeSheets from "./ui/app/organization/staff/class-ui/grade-sheets/GradeSheetsPage"; -import AssignmentsLayout from "./ui/app/single-class/staff/assignments/AssignmentsLayout"; import OrgAssignmentsLayout from "./ui/app/organization/staff/class-ui/assignments/AssignmentsLayout"; -import AssignmentsDisplay from "./ui/app/single-class/staff/assignments/AssignmentsDisplay"; import OrgAssignmentsDisplay from "./ui/app/organization/staff/class-ui/assignments/AssignmentsDisplay"; -import CreateNewAssignment from "./ui/app/single-class/staff/assignments/CreateNewAssignment"; import OrgCreateNewAssignment from "./ui/app/organization/staff/class-ui/assignments/CreateNewAssignment"; -import AssignmentDetails from "./ui/app/single-class/staff/assignments/AssignmentDetails"; import OrgAssignmentDetails from "./ui/app/organization/staff/class-ui/assignments/AssignmentDetails"; -import FinishAssignment from "./ui/app/single-class/staff/assignments/FinishAssignment"; import OrgFinishAssignment from "./ui/app/organization/staff/class-ui/assignments/FinishAssignment"; -import StudentsPage from "./ui/app/single-class/staff/students/StudentsPage"; import OrgStudentsPage from "./ui/app/organization/staff/class-ui/students/StudentsPage"; -import Settings from "./ui/app/single-class/staff/settings/SettingsPage"; import OrgClassSettings from "./ui/app/organization/staff/class-ui/settings/SettingsPage"; import NotFoundPage from "./ui/common/pages/notfound/NotFoundPage"; import UserContext from "./context/UserContext"; @@ -38,55 +28,32 @@ import ClassStudentLayout from "./ui/app/organization/student/common/layout/Clas import ClassesPage from "./ui/app/organization/staff/class-ui/classes/ClassesPage"; import StudentClassesPage from "./ui/app/organization/student/class-ui/classes/StudentClassesPage"; -import StaffLayout from "./ui/app/single-class/staff/common/layout/StaffLayout"; -import Signup from "./ui/onboarding/auth/Signup"; -import OnboardingFlow from "./ui/onboarding/flow/OnboardingFlow"; import Login from "./ui/onboarding/auth/Login"; import ForgotPassword from "./ui/onboarding/auth/ForgotPassword"; import StudentContext from "./context/StudentContext"; import { onLogout } from "./util/auth.util"; import AuthService from "./services/auth.service"; -import AnnouncementStudent from "./ui/app/single-class/student/common/announcements/AnnouncementsPage"; import OrgAnnouncementStudent from "./ui/app/organization/student/class-ui/common/announcements/AnnouncementsPage"; -import StudentLayout from "./ui/app/single-class/student/common/layout/StudentLayout"; -import StaffPage from "./ui/app/single-class/staff/staff/StaffPage"; import OrgStaffPage from "./ui/app/organization/staff/class-ui/staff/StaffPage"; -import StudentGradesPage from "./ui/app/single-class/staff/grade-sheets/StudentGradesPage"; import OrgStudentGradesPage from "./ui/app/organization/staff/class-ui/grade-sheets/StudentGradesPage"; import ProfilePage from "./ui/common/pages/profile/ProfilePage"; -import GradesPage from "./ui/app/single-class/student/common/grades/GradesPage"; import OrgGradesPage from "./ui/app/organization/student/class-ui/common/grades/GradesPage"; -import StudentQuizzesPage from "./ui/app/single-class/student/common/quizzes/StudentQuizzesPage"; import OrgStudentQuizzesPage from "./ui/app/organization/student/class-ui/common/quizzes/StudentQuizzesPage"; -import QuizSessionPage from "./ui/app/single-class/student/common/quizzes/QuizSessionPage"; import OrgQuizSessionPage from "./ui/app/organization/student/class-ui/common/quizzes/QuizSessionPage"; -import QuizResultPage from "./ui/app/single-class/student/common/quizzes/QuizResultPage"; import OrgQuizResultPage from "./ui/app/organization/student/class-ui/common/quizzes/QuizResultPage"; -import CreateQuizPage from "./ui/app/single-class/staff/quizzes/CreateQuizPage"; import OrgCreateQuizPage from "./ui/app/organization/staff/class-ui/quizzes/CreateQuizPage"; -import QuizDetailsPage from "./ui/app/single-class/staff/quizzes/QuizDetailsPage"; import OrgQuizDetailsPage from "./ui/app/organization/staff/class-ui/quizzes/QuizDetailsPage"; -import EditQuizPage from "./ui/app/single-class/staff/quizzes/EditQuizPage"; import OrgEditQuizPage from "./ui/app/organization/staff/class-ui/quizzes/EditQuizPage"; -import QuizSubmissionsPage from "./ui/app/single-class/staff/quizzes/QuizSubmissionsPage"; import OrgQuizSubmissionsPage from "./ui/app/organization/staff/class-ui/quizzes/QuizSubmissionsPage"; -import QuizQuestionsViewPage from "./ui/app/single-class/staff/quizzes/QuizQuestionsViewPage"; import OrgQuizQuestionsViewPage from "./ui/app/organization/staff/class-ui/quizzes/QuizQuestionsViewPage"; import RequirePermission from "./ui/common/auth/RequirePermission"; -import StaffIndex from "./ui/app/single-class/staff/common/StaffIndex"; import OrganizationStaffIndex from "./ui/app/organization/staff/common/OrganizationStaffIndex"; -import AssignmentsPage from "./ui/app/single-class/student/common/assignments/AssignmentsPage"; import OrgAssignmentsPage from "./ui/app/organization/student/class-ui/common/assignments/AssignmentsPage"; -import AssignmentDetailsPage from "./ui/app/single-class/student/common/assignments/AssignmentDetailsPage"; import OrgAssignmentDetailsPage from "./ui/app/organization/student/class-ui/common/assignments/AssignmentDetailsPage"; -import AssignmentSubmissions from "./ui/app/single-class/staff/assignments/AssignmentSubmissions"; import OrgAssignmentSubmissions from "./ui/app/organization/staff/class-ui/assignments/AssignmentSubmissions"; -import StaffMeetingsPage from "./ui/app/single-class/staff/meetings/MeetingsPage"; import OrgStaffMeetingsPage from "./ui/app/organization/staff/class-ui/meetings/MeetingsPage"; -import StudentMeetingsPage from "./ui/app/single-class/student/common/meetings/MeetingsPage"; import OrgStudentMeetingsPage from "./ui/app/organization/student/class-ui/common/meetings/MeetingsPage"; import { MeetingProvider } from "./context/MeetingContext"; -import PopoutMeetingRoom from "./ui/app/single-class/staff/meetings/PopoutMeetingRoom"; const spin = keyframes` 0% { transform: rotate(0deg); } @@ -119,11 +86,10 @@ export default function App() { const Redirect = () => { if (!user) return "/auth/login"; - if (Object.keys(user.tenant).length === 0) return "/onboarding/"; if (user.type === "STUDENT") { - return user.tenant.type === "ORGANIZATION" ? "/student/classes" : "/student/"; + return "/student/classes"; } - return user.tenant.type === "ORGANIZATION" ? "/staff/classes" : "/staff"; + return "/staff/classes"; }; @@ -179,7 +145,7 @@ export default function App() { ) : ( ), - children: user?.tenant?.type === "ORGANIZATION" ? [ + children: [ { element: , children: [ @@ -297,103 +263,6 @@ export default function App() { path: "*", element: , }, - ] : [ - { - index: true, - element: , - }, - { - path: "announcements", - element: , - }, - { - path: "quizzes", - element: , - }, - { - path: "quizzes/create", - element: , - }, - { - path: "quizzes/:id", - element: , - }, - { - path: "quizzes/:id/edit", - element: , - }, - { - path: "quizzes/:id/submissions", - element: , - }, - { - path: "quizzes/:id/questions", - element: , - }, - - { - path: "gradesheets", - element: , - }, - { - path: "profile", - element: , - }, - { - path: "gradesheets/:id", - element: , - }, - { - path: "assignments", - element: , - children: [ - { - index: true, - element: , - }, - { - path: "new", - element: , - }, - { - path: ":id", - element: , - }, - { - path: ":id/edit", - element: , - }, - { - path: ":id/finish", - element: , - }, - { - path: ":id/submissions", - element: , - }, - ], - }, - - { - path: "students", - element: , - }, - { - path: "staff", - element: , - }, - { - path: "meetings", - element: , - }, - { - path: "settings", - element: , - }, - { - path: "*", - element: , - }, ], }, { @@ -404,9 +273,9 @@ export default function App() { user.tenant.type === "ORGANIZATION" ? : ) ) : ( - user?.tenant?.type === "ORGANIZATION" ? : + ), - children: user?.tenant?.type === "ORGANIZATION" ? [ + children: [ { element: , children: [ @@ -466,51 +335,13 @@ export default function App() { path: "*", element: , }, - ] : [ - { - index: true, - element: , - }, - { - path: "announcements", - element: , - }, - { - path: "profile", - element: , - }, - { - path: "grades", - element: , - - }, - { - path: "quizzes", - element: , - }, - { - path: "quizzes/:id/result", - element: , - }, - { - path: "assignments", - element: , - }, - { - path: "assignments/:id", - element: , - }, - { - path: "meetings", - element: , - }, ], }, { - path: user?.tenant?.type === "ORGANIZATION" ? "/student/classes/:classId/quizzes/:id/take" : "/student/quizzes/:id/take", + path: "/student/classes/:classId/quizzes/:id/take", element: user && user.type === "STUDENT" ? ( - Object.keys(user.tenant).length === 0 ? : (user.tenant.type === "ORGANIZATION" ? : ) + ) : ( ), diff --git a/web/src/ui/app/single-class/staff/meetings/MeetingRoom.jsx b/web/src/ui/app/organization/staff/class-ui/meetings/MeetingRoom.jsx similarity index 99% rename from web/src/ui/app/single-class/staff/meetings/MeetingRoom.jsx rename to web/src/ui/app/organization/staff/class-ui/meetings/MeetingRoom.jsx index 4572330a..4d816bf2 100644 --- a/web/src/ui/app/single-class/staff/meetings/MeetingRoom.jsx +++ b/web/src/ui/app/organization/staff/class-ui/meetings/MeetingRoom.jsx @@ -17,8 +17,8 @@ import { faArrowUpRightFromSquare, faLink, } from "@fortawesome/free-solid-svg-icons"; -import MeetingService from "../../../../../services/meeting.service"; -import { useMeeting } from "../../../../../context/MeetingContext"; +import MeetingService from "../../../../../../services/meeting.service"; +import { useMeeting } from "../../../../../../context/MeetingContext"; import { Room, RoomEvent, diff --git a/web/src/ui/app/organization/staff/class-ui/meetings/MeetingsPage.jsx b/web/src/ui/app/organization/staff/class-ui/meetings/MeetingsPage.jsx index 5749803b..c3106bee 100644 --- a/web/src/ui/app/organization/staff/class-ui/meetings/MeetingsPage.jsx +++ b/web/src/ui/app/organization/staff/class-ui/meetings/MeetingsPage.jsx @@ -14,7 +14,7 @@ import { import MeetingService from "../../../../../../services/meeting.service"; import EmptyState from "../../../../../common/notice/EmptyState"; import Modal from "../../../../../common/prompts/Modal"; -import MeetingRoom from "../../../../single-class/staff/meetings/MeetingRoom"; +import MeetingRoom from "./MeetingRoom"; import { ProfileListBody, SkeletonTable } from "../../../../../common/skeleton/Skeleton"; import { useMeeting } from "../../../../../../context/MeetingContext"; diff --git a/web/src/ui/app/single-class/staff/announcements/AnnouncementCard.jsx b/web/src/ui/app/single-class/staff/announcements/AnnouncementCard.jsx deleted file mode 100644 index dda020a8..00000000 --- a/web/src/ui/app/single-class/staff/announcements/AnnouncementCard.jsx +++ /dev/null @@ -1,196 +0,0 @@ -import { - faCircleUser, - faEllipsisV, - faTrashAlt, -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import styled from "styled-components"; -import { formatTime } from "../../../../../util/time.util"; -import { useState } from "react"; -import AnnouncementService from "../../../../../services/announcement.service"; -import { trackEvent } from "../../../../../lib/analytics"; - -const Card = styled("div")` - width: 100%; - border-bottom: 1px solid var(--color-border); - background: white; - padding: 16px 0; -`; - -const TopRow = styled("div")` - width: 100%; - display: flex; - align-items: flex-start; - justify-content: space-between; - position: relative; -`; - -const Left = styled("div")` - display: flex; - gap: 12px; - align-items: center; -`; - -const Avatar = styled("div")` - width: 36px; - height: 36px; - border-radius: 50%; - background: #f1eaff; - display: grid; - place-items: center; - flex-shrink: 0; -`; - -const ProfileAvatar = styled(FontAwesomeIcon)` - font-size: 18px; - width: 35px; - height: auto; -`; - -const Details = styled("div")` - display: flex; - flex-direction: column; - gap: 2px; -`; - -const ProfileName = styled("p")` - font-size: 16px; - font-weight: 400; - margin: 0; -`; - -const DateText = styled("p")` - font-size: 12px; - color: #0000008c; - margin: 0; -`; - -const Optionss = styled("button")` - width: 34px; - height: 34px; - border-radius: 10px; - border: 1px solid transparent; - background: transparent; - cursor: pointer; - display: grid; - place-items: center; - transition: 0.2s; - z-index: 4; - - &:hover { - background: var(--color-background-secondary); - border-color: #00000012; - } -`; - -const Menu = styled("div")` - position: absolute; - top: 42px; - right: 0; - background: white; - width: 140px; - border-radius: 12px; - border: 1px solid var(--color-border); - box-shadow: 0 12px 30px #0000001a; - z-index: 100; - overflow: hidden; - text-align: center; -`; - -const MenuItem = styled("button")` - cursor: pointer; - width: 100%; - display: flex; - align-items: center; - gap: 10px; - text-align: left; - background: none; - border: none; - padding: 12px 12px; - font-size: 16px; - - &:hover { - background: var(--color-background-secondary); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } -`; - -const Para = styled("p")` - margin: 12px 0 0; - font-size: 14px; - line-height: 1.7; - color: #000000d0; - white-space: pre-wrap; -`; - -export default function AnnouncementCard({ - announcement, - showOptions, - setShowOptions, - removeAnnouncement, -}) { - const announcementService = new AnnouncementService(); - const { id, created_at: createdAt, author, content } = announcement; - - const [pending, setPending] = useState(false); - const [error, setError] = useState(); - - const deleteAnnouncement = async () => { - setPending(true); - setError(null); - - try { - if (!id) { - removeAnnouncement(); - return; - } - - await announcementService.deleteAnnouncement(id); - trackEvent("delete_announcement", { announcement_id: id }); - removeAnnouncement(); - } catch { - setError(true); - } finally { - setPending(false); - } - }; - - return ( - - - - - - - -
- {author} - {formatTime(new Date(createdAt))} -
-
- - setShowOptions()}> - - - - {showOptions && ( - - deleteAnnouncement()} - disabled={pending} - > - - Delete - - - )} -
- - {content} -
- ); -} diff --git a/web/src/ui/app/single-class/staff/announcements/AnnouncementCreateModal.jsx b/web/src/ui/app/single-class/staff/announcements/AnnouncementCreateModal.jsx deleted file mode 100644 index 19a50d3d..00000000 --- a/web/src/ui/app/single-class/staff/announcements/AnnouncementCreateModal.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import styled from "styled-components"; -import Button from "../../../../common/input/Button"; -import { useContext, useEffect, useRef, useState } from "react"; -import AnnouncementService from "../../../../../services/announcement.service"; -import UserContext from "../../../../../context/UserContext"; -import { trackEvent } from "../../../../../lib/analytics"; - -const Wrapper = styled("form")` - display: flex; - flex-direction: column; - gap: 14px; -`; - -const ModalTitle = styled("p")` - font-size: 24px; - margin-bottom: 5px; -`; - -const TextArea = styled("textarea")` - width: 100%; - height: 120px; - font-size: 14px; - border: 1px solid black; - border-radius: 14px; - padding: 14px 16px; - resize: none; - transition: 0.2s; - - &::placeholder { - color: #0000006b; - } -`; - -const Actions = styled("div")` - display: flex; - justify-content: flex-end; -`; - -const SendButton = styled(Button)` - height: 40px; - padding: 0 18px; - border-radius: 10px; - font-weight: 600; - width: 100%; -`; - -export default function AnnouncementCreateModal({ addAnnouncement }) { - const announcementService = new AnnouncementService(); - const { user } = useContext(UserContext); - - const [pending, setPending] = useState(false); - const [content, setContent] = useState(""); - const [error, setError] = useState(false); - - const textareaRef = useRef(null); - useEffect(() => { - textareaRef.current?.focus(); - }, []); - - const submit = async (e) => { - e.preventDefault(); - - setPending(true); - setError(false); - - try { - const created = - await announcementService.createAnnouncement(content); - - addAnnouncement({ - id: created.id, - content, - author: user.name, - created_at: new Date(), - }); - - trackEvent("create_announcement"); - - setContent(""); - e.target.reset(); - } catch { - setError(true); - } finally { - setPending(false); - } - }; - - return ( - - Send an announcement - -