diff --git a/openspec/changes/add-question-of-the-day/.openspec.yaml b/openspec/changes/add-question-of-the-day/.openspec.yaml new file mode 100644 index 000000000..f05b045c8 --- /dev/null +++ b/openspec/changes/add-question-of-the-day/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-27 diff --git a/openspec/changes/add-question-of-the-day/design.md b/openspec/changes/add-question-of-the-day/design.md new file mode 100644 index 000000000..d95595be6 --- /dev/null +++ b/openspec/changes/add-question-of-the-day/design.md @@ -0,0 +1,40 @@ +## Context + +The application introduces the "Question of the Day" (QOTD) feature to enhance student daily retention. We utilize the `/api/v2.4/daily_questions/` JSON endpoint to power a native interactive quiz workflow. + +## Goals / Non-Goals + +**Goals:** +- Update `InstituteSettings` to parse and store `qotdEnabled`. +- Fetch and dynamically parse daily questions data with support for polymorphic subject/difficulty payloads. +- Provide a dual-screen flow: `QotdOverviewScreen` (summary & statistics) and `QotdQuizScreen` (interactive stepper quiz). +- Use `AppHtmlV2` with MathJax SVG rendering support. + +**Non-Goals:** +- Using a WebView. +- Offline persistence via Drift (QOTD is time-sensitive and daily-scoped, requiring live backend validation). + +## Decisions + +**1. Native Interactive Quiz Architecture with Riverpod** +- **Rationale**: Manages question navigation, option selection, optimistic submissions, and solution state via `QotdQuizController`. + +**2. Robust Dynamic Subject Parsing in `QotdDto`** +- **Rationale**: The backend returns subjects in varied shapes (flat strings, `{name: ...}` maps, lists, category tags). A dynamic extractor handles all shapes without artificial hardcoded fallbacks. + +**3. Streamlined Question Header & Unified Metadata Pill** +- **Rationale**: Placing progress only in `AppHeader` and grouping `[Subject • Difficulty • Type]` into a single pill with `#0F172A` high-contrast text removes UI clutter while improving scannability. + +**4. `AppHtmlV2` MathJax SVG Rendering** +- **Rationale**: Decodes and renders custom MathJax SVGs natively within the HTML flow. + +**5. Direct Online-Only Repository (`QotdRepository`)** +- **Rationale**: QOTD features are inherently time-sensitive and daily-scoped. Submissions and daily resets require live server validation, making local Drift cache synchronization unnecessary and intentionally omitted. + +**6. Conditional Cache Invalidation on Quiz Exit** +- **Rationale**: Tracking `hasSubmittedNewAnswer` avoids redundant network calls and loading states when users are simply viewing solutions or navigating without answering, ensuring zero-latency return transitions to the overview. + +## Risks / Trade-offs + +- **Risk: Varying API Payload Structures** → Mitigation: Dynamic extractor in `QotdDto` ensures resilient parsing across different question serializers. +- **Trade-off: No Offline Persistence** → Mitigation: Daily questions rely on real-time server evaluations; network loading states are gracefully covered by structured shimmer skeletons. diff --git a/openspec/changes/add-question-of-the-day/proposal.md b/openspec/changes/add-question-of-the-day/proposal.md new file mode 100644 index 000000000..e0090652a --- /dev/null +++ b/openspec/changes/add-question-of-the-day/proposal.md @@ -0,0 +1,27 @@ +## Why + +We need to add the "Question of the Day" (QOTD) feature to the application. This feature increases daily student engagement by providing a quick, daily learning activity accessible directly from the dashboard navigation with an interactive quiz experience, progress tracking, and detailed explanations. + +## What Changes + +- Add boolean flag `qotdEnabled` to the `InstituteSettings` data model. +- Add a new "Daily Questions" menu item in the Dashboard Drawer. +- Introduce native `QotdOverviewScreen` and `QotdQuizScreen` driven by Riverpod (`qotdQuizControllerProvider`). +- Implement dynamic `QotdDto` parsing for subject, difficulty, question type, options, and past attempts. +- Render questions using `AppHtmlV2` with custom MathJax SVG decoding support. + +## Capabilities + +### New Capabilities +- `daily-questions`: Feature providing landing overview, interactive multi-question quiz flow, option selection, attempt submission, and solution explanations. + +### Modified Capabilities +- `lms-home-paid-active`: Adding the "Daily Questions" item to the drawer navigation. + +## Impact + +- `packages/core/lib/data/config/institute_settings.dart`: Modified to support the new `qotd_enabled` JSON field. +- `packages/core/lib/data/models/qotd_dto.dart`: Robust DTO parsing subject, difficulty, type, options, and attempts. +- `packages/core/lib/widgets/app_html_v2.dart`: Custom MathJax SVG decoding. +- `packages/testpress/lib/screens/dashboard/qotd/`: Contains `QotdOverviewScreen`, `QotdQuizScreen`, controller, and widgets. +- `packages/testpress/lib/navigation/app_router.dart`: Added routing for the `/qotd` screen. diff --git a/openspec/changes/add-question-of-the-day/specs/daily-questions/spec.md b/openspec/changes/add-question-of-the-day/specs/daily-questions/spec.md new file mode 100644 index 000000000..9fff79584 --- /dev/null +++ b/openspec/changes/add-question-of-the-day/specs/daily-questions/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Daily Questions Overview and Interactive Quiz +The system SHALL provide dedicated native screens to display the Question of the Day overview and interactive quiz stepper. + +#### Scenario: Overview Screen +- **WHEN** the user navigates to `/qotd` +- **THEN** the system MUST display the daily questions overview screen with the completion gauge, date, and CTA to start/resume or view solutions. + +#### Scenario: Interactive Quiz Stepper +- **WHEN** the user starts the quiz +- **THEN** the system MUST render questions using `AppHtmlV2` with MathJax SVG decoding. +- **AND** it MUST show a unified question metadata pill (`[Subject • Difficulty • Type]`) above the question text. +- **AND** it MUST display the single question progress in `AppHeader` with a top-aligned back button. + +#### Scenario: Dynamic Data Model Parsing +- **WHEN** `QotdDto` parses question payloads from the API +- **THEN** it MUST dynamically extract subject, difficulty, and question type without hardcoding defaults. +- **AND** `InstituteSettings` MUST correctly parse the `qotd_enabled` boolean field. + +### Requirement: Online-Only Repository Operations +The system SHALL query daily questions and submit answers directly against the network API without local Drift caching, ensuring daily reset states and answer evaluations are strictly governed by the backend. + +#### Scenario: Real-Time Network State +- **WHEN** the user opens the overview or submits an answer +- **THEN** `QotdRepository` MUST communicate directly with `DataSource` without persisting attempts to local database tables. + +#### Scenario: Solution Review Navigation +- **WHEN** the user closes the quiz after reviewing solutions without submitting new answers +- **THEN** the system MUST return immediately to the overview without invalidating provider cache or making network calls. diff --git a/openspec/changes/add-question-of-the-day/specs/lms-home-paid-active/spec.md b/openspec/changes/add-question-of-the-day/specs/lms-home-paid-active/spec.md new file mode 100644 index 000000000..835dc0c66 --- /dev/null +++ b/openspec/changes/add-question-of-the-day/specs/lms-home-paid-active/spec.md @@ -0,0 +1,15 @@ +## MODIFIED Requirements + +### Requirement: Top App Bar & Navigation Access +The system SHALL provide a persistent top app bar (DashboardHeader) with access to global navigation options, including the navigation drawer. + +#### Scenario: Hamburger Menu Placeholder +- **WHEN** the `DashboardHeader` is rendered +- **THEN** it MUST include a hamburger menu icon (`Icons.menu_rounded`) +- **AND** the actual menu sidebar functionality is deferred to a separate/future spec change. + +#### Scenario: Daily Questions Drawer Item +- **GIVEN** `instituteSettings.qotdEnabled` is `true` +- **WHEN** the `DashboardDrawer` is rendered +- **THEN** it MUST include a "Daily Questions" item with a calendar icon +- **AND** tapping it MUST route the user to the `/qotd` screen. diff --git a/openspec/changes/add-question-of-the-day/tasks.md b/openspec/changes/add-question-of-the-day/tasks.md new file mode 100644 index 000000000..3538b12a5 --- /dev/null +++ b/openspec/changes/add-question-of-the-day/tasks.md @@ -0,0 +1,20 @@ +## 1. Data Model & Architecture +- [x] 1.1 Update `InstituteSettings` model to include `final bool qotdEnabled;` +- [x] 1.2 Update `fromJson` and `toJson` serialization logic for `qotd_enabled` +- [x] 1.3 Create `QotdDto`, `QotdOptionDto`, `QotdSubmitResponseDto`, and `QotdSummaryDto` +- [x] 1.4 Implement dynamic `extractSubject` parser in `QotdDto` to handle polymorphic API shapes without hardcoding +- [x] 1.5 Implement `QotdRepository` and `qotdQuizControllerProvider` (Riverpod) + +## 2. Screens & UI Flow +- [x] 2.1 Implement `QotdOverviewScreen` with statistics gauge, completion summary, and date tracking +- [x] 2.2 Implement interactive `QotdQuizScreen` with multi-question stepper and solution view +- [x] 2.3 Configure `/qotd` and quiz routes in `AppRouter` +- [x] 2.4 Add "Daily Questions" item with calendar icon to `DashboardDrawer` (guarded by `qotdEnabled`) + +## 3. Visual Polish & Design System Governance +- [x] 3.1 Single unified metadata pill (`Subject • Difficulty • Type`) using design tokens and `#0F172A` text +- [x] 3.2 Streamlined `AppHeader` with top-aligned back button and progress indicator +- [x] 3.3 Remove redundant question labels and sub-prompts +- [x] 3.4 Support custom MathJax SVG rendering in `AppHtmlV2` + + diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 37dac5bcf..97df986e2 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -50,6 +50,7 @@ export 'widgets/app_toast.dart'; export 'widgets/session_expired_dialog.dart'; export 'widgets/app_confirmation_dialog.dart'; export 'widgets/ai_composer.dart'; +export 'widgets/donut_chart.dart'; // Shell export 'shell/app_shell.dart'; diff --git a/packages/core/lib/data/config/institute_settings.dart b/packages/core/lib/data/config/institute_settings.dart index 88e991f7e..befef0b00 100644 --- a/packages/core/lib/data/config/institute_settings.dart +++ b/packages/core/lib/data/config/institute_settings.dart @@ -77,6 +77,7 @@ class InstituteSettings { final String? learnlensOrgID; final bool disableStudentReport; + final bool qotdEnabled; const InstituteSettings({ required this.domainUrl, @@ -116,6 +117,7 @@ class InstituteSettings { required this.currentPaymentApp, required this.learnlensEnabled, required this.disableStudentReport, + required this.qotdEnabled, this.learnlensOrgID, this.videoWatermarkType, this.videoWatermarkPosition, @@ -190,6 +192,7 @@ class InstituteSettings { learnlensEnabled: json['learnlens_enabled'] as bool? ?? false, learnlensOrgID: json['learnlens_organization_id'] as String?, disableStudentReport: json['disable_student_report'] as bool? ?? false, + qotdEnabled: json['qotd_enabled'] as bool? ?? false, videoWatermarkType: switch (watermarkType) { 'dynamic' => VideoWatermarkType.dynamic, @@ -256,6 +259,7 @@ class InstituteSettings { 'learnlens_enabled': learnlensEnabled, 'learnlens_organization_id': learnlensOrgID, 'disable_student_report': disableStudentReport, + 'qotd_enabled': qotdEnabled, 'video_watermark_type': switch (videoWatermarkType) { VideoWatermarkType.dynamic => 'dynamic', VideoWatermarkType.static => 'static', diff --git a/packages/core/lib/data/data.dart b/packages/core/lib/data/data.dart index 8cfc54c42..7fdbd7f02 100644 --- a/packages/core/lib/data/data.dart +++ b/packages/core/lib/data/data.dart @@ -37,6 +37,7 @@ export 'models/quiz_review_result_dto.dart'; export 'models/custom_test_config_dto.dart'; export 'models/custom_exam_generation_dto.dart'; export 'models/learnlens_dto.dart'; +export 'models/qotd_dto.dart'; // Database export 'db/tables/dashboard_tables.dart'; @@ -75,10 +76,12 @@ export 'repositories/doubt_repository.dart'; export 'repositories/live_classes_repository.dart'; export 'repositories/repository_providers.dart'; export 'repositories/institute_settings_repository.dart'; +export 'repositories/qotd_repository.dart'; export 'providers/bookmark_provider.dart'; export 'providers/announcements_provider.dart'; export 'providers/institute_settings_provider.dart'; export 'providers/doubt_providers.dart'; +export 'providers/qotd_provider.dart'; //service export 'services/downloads_service.dart'; diff --git a/packages/core/lib/data/models/qotd_dto.dart b/packages/core/lib/data/models/qotd_dto.dart new file mode 100644 index 000000000..524f10adb --- /dev/null +++ b/packages/core/lib/data/models/qotd_dto.dart @@ -0,0 +1,203 @@ +/// Normalized question type — resolved once at parse time in [QotdDto.fromJson]. +enum QotdQuestionType { + singleCorrect, + multipleCorrect; + + static QotdQuestionType from(String? raw) { + if (raw == null || raw.trim().isEmpty) return singleCorrect; + final upper = raw.trim().toUpperCase(); + if (upper == 'C' || + upper == 'M' || + upper == 'MCA' || + upper == 'MULTIPLE' || + upper == 'MULTIPLE_TYPE' || + upper == 'MULTIPLESELECT' || + upper == 'MULTIPLE_CHOICE' || + upper == 'MULTIPLE CHOICE' || + upper == 'MULTIPLE CORRECT' || + upper.contains('MULTIPLE')) { + return multipleCorrect; + } + return singleCorrect; + } +} + +class QotdDto { + final int id; + final int questionId; + final String htmlContent; + final String? subject; + final String? difficulty; + final String? type; + final List options; + final QotdSubmitResponseDto? pastAttempt; + + const QotdDto({ + required this.id, + required this.questionId, + required this.htmlContent, + this.subject, + this.difficulty, + this.type, + this.options = const [], + this.pastAttempt, + }); + + /// Normalized question type — derived from [type] at parse time. + QotdQuestionType get questionType => QotdQuestionType.from(type); + + factory QotdDto.fromJson(Map json) { + final Map data = + json['question'] as Map? ?? json; + + final html = + data['text'] ?? + data['question_html'] ?? + data['html'] ?? + json['text'] ?? + json['question_html'] ?? + json['html'] ?? + ''; + final optionsRaw = + (data['options'] ?? + data['answers'] ?? + json['options'] ?? + json['answers'] ?? + []) + as List; + + final dynamic subjectValue = + data['subject'] ?? + data['subject_name'] ?? + json['subject_name'] ?? + json['subject']; + final rawSubject = + (subjectValue is Map + ? (subjectValue['name'] ?? subjectValue['title']) + : subjectValue) + ?.toString(); + + final rawDifficulty = + (data['difficulty'] ?? + data['difficulty_level'] ?? + json['difficulty_level'] ?? + json['difficulty']) + as String?; + + final rawType = + (data['type'] ?? + data['question_type'] ?? + json['question_type'] ?? + json['type']) + as String?; + + return QotdDto( + id: + json['daily_question_id'] as int? ?? + json['id'] as int? ?? + data['id'] as int? ?? + 0, + questionId: + json['question_id'] as int? ?? + data['question_id'] as int? ?? + data['id'] as int? ?? + 0, + htmlContent: html.toString(), + subject: (rawSubject != null && rawSubject.trim().isNotEmpty) + ? rawSubject.trim() + : null, + + difficulty: rawDifficulty, + type: rawType, + options: optionsRaw + .map((e) => QotdOptionDto.fromJson(e as Map)) + .toList(), + pastAttempt: json['attempt'] != null + ? QotdSubmitResponseDto.fromJson( + json['attempt'] as Map, + ) + : (data['attempt'] != null + ? QotdSubmitResponseDto.fromJson( + data['attempt'] as Map, + ) + : null), + ); + } +} + +class QotdOptionDto { + final int id; + final String htmlContent; + + const QotdOptionDto({required this.id, required this.htmlContent}); + + factory QotdOptionDto.fromJson(Map json) { + final html = json['text_html'] ?? json['text'] ?? json['content'] ?? ''; + return QotdOptionDto( + id: json['id'] as int? ?? 0, + htmlContent: html.toString(), + ); + } +} + +class QotdSubmitResponseDto { + final bool isCorrect; + final String explanation; + final List selectedAnswerIds; + final List correctAnswerIds; + final Map? rawData; + + const QotdSubmitResponseDto({ + required this.isCorrect, + required this.explanation, + this.selectedAnswerIds = const [], + this.correctAnswerIds = const [], + this.rawData, + }); + + factory QotdSubmitResponseDto.fromJson(Map json) { + final selectedRaw = json['selected_answer_ids'] ?? json['answer_ids']; + final correctRaw = json['correct_answer_ids']; + + return QotdSubmitResponseDto( + isCorrect: json['is_correct'] as bool? ?? false, + explanation: json['explanation']?.toString() ?? '', + selectedAnswerIds: selectedRaw is List + ? selectedRaw.map((e) => e as int).toList() + : (json['answer_id'] != null ? [json['answer_id'] as int] : []), + correctAnswerIds: correctRaw is List + ? correctRaw.map((e) => e as int).toList() + : [], + rawData: json, + ); + } +} + +class QotdSummaryDto { + final int totalCount; + final int attemptedCount; + final int correctCount; + final int incorrectCount; + final int unansweredCount; + final String? status; + + const QotdSummaryDto({ + this.totalCount = 0, + this.attemptedCount = 0, + this.correctCount = 0, + this.incorrectCount = 0, + this.unansweredCount = 0, + this.status, + }); + + factory QotdSummaryDto.fromJson(Map json) { + return QotdSummaryDto( + totalCount: json['total_count'] as int? ?? 0, + attemptedCount: json['attempted_count'] as int? ?? 0, + correctCount: json['correct_count'] as int? ?? 0, + incorrectCount: json['incorrect_count'] as int? ?? 0, + unansweredCount: json['unanswered_count'] as int? ?? 0, + status: json['status'] as String?, + ); + } +} diff --git a/packages/core/lib/data/providers/qotd_provider.dart b/packages/core/lib/data/providers/qotd_provider.dart new file mode 100644 index 000000000..d0ebcc482 --- /dev/null +++ b/packages/core/lib/data/providers/qotd_provider.dart @@ -0,0 +1,19 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../models/qotd_dto.dart'; +import '../repositories/qotd_repository.dart'; + +part 'qotd_provider.g.dart'; + +/// Provider to fetch the Question of the Day list from the repository. +@riverpod +Future> qotd(QotdRef ref) async { + final repository = ref.watch(qotdRepositoryProvider); + return repository.getQuestions(); +} + +/// Provider to fetch overall QOTD statistics/summary. +@riverpod +Future qotdSummary(QotdSummaryRef ref) async { + final repository = ref.watch(qotdRepositoryProvider); + return repository.getSummary(); +} diff --git a/packages/core/lib/data/providers/qotd_provider.g.dart b/packages/core/lib/data/providers/qotd_provider.g.dart new file mode 100644 index 000000000..ad162a0cb --- /dev/null +++ b/packages/core/lib/data/providers/qotd_provider.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'qotd_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$qotdHash() => r'b30908ed4794ba170efc7e78b59f5a13fca9db55'; + +/// Provider to fetch the Question of the Day list from the repository. +/// +/// Copied from [qotd]. +@ProviderFor(qotd) +final qotdProvider = AutoDisposeFutureProvider>.internal( + qotd, + name: r'qotdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$qotdHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef QotdRef = AutoDisposeFutureProviderRef>; +String _$qotdSummaryHash() => r'c40dc1b85ace9825f71f225485b4a6f33de84bbe'; + +/// Provider to fetch overall QOTD statistics/summary. +/// +/// Copied from [qotdSummary]. +@ProviderFor(qotdSummary) +final qotdSummaryProvider = AutoDisposeFutureProvider.internal( + qotdSummary, + name: r'qotdSummaryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$qotdSummaryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef QotdSummaryRef = AutoDisposeFutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/core/lib/data/repositories/qotd_repository.dart b/packages/core/lib/data/repositories/qotd_repository.dart new file mode 100644 index 000000000..e720122a3 --- /dev/null +++ b/packages/core/lib/data/repositories/qotd_repository.dart @@ -0,0 +1,36 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/qotd_dto.dart'; +import '../sources/data_source.dart'; +import '../sources/data_source_provider.dart'; + +/// Repository for Question of the Day (QOTD) business logic and data access. +class QotdRepository { + final DataSource _dataSource; + + const QotdRepository({required DataSource dataSource}) + : _dataSource = dataSource; + + /// Fetches the list of daily questions. + Future> getQuestions() async { + return _dataSource.getQotdQuestions(); + } + + /// Submits an attempt for a specific question and options. + Future submitAttempt({ + required int questionId, + required List optionIds, + }) async { + return _dataSource.submitQotdAttempt(questionId, optionIds); + } + + /// Fetches the overall QOTD statistics and summary. + Future getSummary() async { + return _dataSource.getQotdSummary(); + } +} + +/// Provides the [QotdRepository] instance. +final qotdRepositoryProvider = Provider((ref) { + final dataSource = ref.watch(dataSourceProvider); + return QotdRepository(dataSource: dataSource); +}); diff --git a/packages/core/lib/data/sources/data_source.dart b/packages/core/lib/data/sources/data_source.dart index 9414a050a..7ff6c4f4e 100644 --- a/packages/core/lib/data/sources/data_source.dart +++ b/packages/core/lib/data/sources/data_source.dart @@ -359,4 +359,18 @@ abstract class DataSource { /// Delete a bookmark by its server-side ID. Future deleteBookmark(String bookmarkId); + + // ── Question of the Day ────────────────────────────────────────────────── + + /// Fetch the list of daily questions. + Future> getQotdQuestions(); + + /// Submit an answer to a specific QOTD. + Future submitQotdAttempt( + int questionId, + List optionIds, + ); + + /// Fetch overall QOTD statistics/summary. + Future getQotdSummary(); } diff --git a/packages/core/lib/data/sources/http_data_source.dart b/packages/core/lib/data/sources/http_data_source.dart index 8667f0b5e..35c8cb9d7 100644 --- a/packages/core/lib/data/sources/http_data_source.dart +++ b/packages/core/lib/data/sources/http_data_source.dart @@ -1277,6 +1277,67 @@ class HttpDataSource implements DataSource { ); } + // ── Question of the Day ────────────────────────────────────────────────── + + @override + Future> getQotdQuestions() async { + return performNetworkRequest( + _dio.get('/api/v3/qotd/questions/'), + fromJson: (data) { + if (data == null || data is! Map) return []; + + final questions = data['questions'] as List? ?? []; + final attempts = data['attempts'] as List? ?? []; + + if (questions.isEmpty) return []; + + return questions.map((q) { + final qMap = Map.from(q as Map); + final dqId = qMap['daily_question_id'] ?? qMap['id']; + + final attempt = attempts.firstWhere( + (a) => (a as Map)['daily_question_id'] == dqId, + orElse: () => null, + ); + + if (attempt != null) { + qMap['attempt'] = attempt; + } + + return QotdDto.fromJson(qMap); + }).toList(); + }, + ); + } + + @override + Future submitQotdAttempt( + int questionId, + List optionIds, + ) async { + return performNetworkRequest( + _dio.post( + '/api/v3/qotd/attempts/', + data: {'daily_question_id': questionId, 'answer_ids': optionIds}, + ), + fromJson: (data) => + QotdSubmitResponseDto.fromJson(data as Map), + ); + } + + @override + Future getQotdSummary() async { + return performNetworkRequest( + _dio.get('/api/v3/qotd/summary/'), + fromJson: (data) { + if (data == null || data is! Map) { + return const QotdSummaryDto(); + } + return QotdSummaryDto.fromJson(data as Map); + }, + ); + } + // ── Custom Exams ──────────────────────────────────────────────────────── @override diff --git a/packages/core/lib/data/sources/mock_data_source.dart b/packages/core/lib/data/sources/mock_data_source.dart index fa8f6c9ac..c5e49909f 100644 --- a/packages/core/lib/data/sources/mock_data_source.dart +++ b/packages/core/lib/data/sources/mock_data_source.dart @@ -2159,4 +2159,143 @@ class MockDataSource implements DataSource { state: 'Running', ); } + // ── Question of the Day ────────────────────────────────────────────────── + + static const List mockQotdQuestions = [ + QotdDto( + id: 1, + questionId: 101, + htmlContent: '

What is the capital of France?

', + subject: 'Geography', + difficulty: 'Easy', + type: 'SINGLE_CHOICE', + options: [ + QotdOptionDto(id: 1001, htmlContent: 'Berlin'), + QotdOptionDto(id: 1002, htmlContent: 'Madrid'), + QotdOptionDto(id: 1003, htmlContent: 'Paris'), + QotdOptionDto(id: 1004, htmlContent: 'Rome'), + ], + ), + QotdDto( + id: 2, + questionId: 102, + htmlContent: '

Which of the following are programming languages?

', + subject: 'Computer Science', + difficulty: 'Medium', + type: 'MULTIPLE_CHOICE', + options: [ + QotdOptionDto(id: 1005, htmlContent: 'Kotlin'), + QotdOptionDto(id: 1006, htmlContent: 'Python'), + QotdOptionDto(id: 1007, htmlContent: 'HTML'), + QotdOptionDto(id: 1008, htmlContent: 'Java'), + ], + ), + QotdDto( + id: 3, + questionId: 103, + htmlContent: + '

What is the primary function of RAM in a computer system?

', + subject: 'Hardware', + difficulty: 'Easy', + type: 'SINGLE_CHOICE', + options: [ + QotdOptionDto(id: 1009, htmlContent: 'Long term storage'), + QotdOptionDto(id: 1010, htmlContent: 'Temporary working memory'), + QotdOptionDto(id: 1011, htmlContent: 'Processing graphics'), + QotdOptionDto(id: 1012, htmlContent: 'Cooling the CPU'), + ], + ), + QotdDto( + id: 4, + questionId: 104, + htmlContent: + '

Which of the following are mobile operating systems?

', + subject: 'Technology', + difficulty: 'Medium', + type: 'MULTIPLE_CHOICE', + options: [ + QotdOptionDto(id: 1013, htmlContent: 'Android'), + QotdOptionDto(id: 1014, htmlContent: 'iOS'), + QotdOptionDto(id: 1015, htmlContent: 'Windows Server'), + QotdOptionDto(id: 1016, htmlContent: 'macOS'), + ], + ), + QotdDto( + id: 5, + questionId: 105, + htmlContent: '

What does CPU stand for?

', + subject: 'Computer Science', + difficulty: 'Easy', + type: 'SINGLE_CHOICE', + options: [ + QotdOptionDto(id: 1017, htmlContent: 'Central Processing Unit'), + QotdOptionDto(id: 1018, htmlContent: 'Computer Personal Unit'), + QotdOptionDto(id: 1019, htmlContent: 'Control Program Utility'), + QotdOptionDto(id: 1020, htmlContent: 'Central Performance User'), + ], + ), + ]; + + @override + Future> getQotdQuestions() async { + await Future.delayed(const Duration(milliseconds: 300)); + return mockQotdQuestions; + } + + @override + Future submitQotdAttempt( + int questionId, + List optionIds, + ) async { + await Future.delayed(const Duration(milliseconds: 300)); + + final Map, String)> answers = { + 1: ([1003], 'Paris is the capital and most populous city of France.'), + 2: ( + [1005, 1006, 1008], + 'Kotlin, Python, and Java are programming languages. HTML is a markup language used for structuring web pages.', + ), + 3: ( + [1010], + 'Random Access Memory (RAM) provides temporary storage for data that is currently being used by the CPU, allowing for quick read and write access.', + ), + 4: ( + [1013, 1014], + 'Android and iOS are mobile operating systems designed for smartphones and tablets.', + ), + 5: ( + [1017], + 'CPU stands for Central Processing Unit, which performs the basic arithmetical, logical, and input/output operations.', + ), + }; + + final answerInfo = + answers[questionId] ?? + ([1003], 'Explanation provided for the question.'); + final correctIds = answerInfo.$1; + final explanation = answerInfo.$2; + + final isCorrect = + optionIds.length == correctIds.length && + optionIds.every((id) => correctIds.contains(id)); + + return QotdSubmitResponseDto( + isCorrect: isCorrect, + explanation: explanation, + selectedAnswerIds: optionIds, + correctAnswerIds: correctIds, + ); + } + + @override + Future getQotdSummary() async { + await Future.delayed(const Duration(milliseconds: 200)); + return const QotdSummaryDto( + totalCount: 5, + attemptedCount: 0, + correctCount: 0, + incorrectCount: 0, + unansweredCount: 5, + ); + } } diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index b6bcfd50f..1b6deac21 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -5770,6 +5770,234 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Selected filter: {category}. Tap to change filter'** String announcementsSelectedFilter(String category); + + /// No description provided for @qotdTitle. + /// + /// In en, this message translates to: + /// **'Daily Questions'** + String get qotdTitle; + + /// No description provided for @qotdEmptyState. + /// + /// In en, this message translates to: + /// **'No questions available today.'** + String get qotdEmptyState; + + /// No description provided for @qotdErrorFailedToLoad. + /// + /// In en, this message translates to: + /// **'Failed to load daily questions'** + String get qotdErrorFailedToLoad; + + /// No description provided for @qotdRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get qotdRetry; + + /// No description provided for @qotdAttemptedCount. + /// + /// In en, this message translates to: + /// **'{attempted} / {total} Attempted'** + String qotdAttemptedCount(int attempted, int total); + + /// No description provided for @qotdSubtitleCompleted. + /// + /// In en, this message translates to: + /// **'Fantastic job! You\'ve conquered today\'s challenge. Keep up the momentum!'** + String get qotdSubtitleCompleted; + + /// No description provided for @qotdSubtitleInProgress. + /// + /// In en, this message translates to: + /// **'You\'re on track to finish your daily goal. Just {remaining} more questions to go!'** + String qotdSubtitleInProgress(int remaining); + + /// No description provided for @qotdSubtitleNotStarted. + /// + /// In en, this message translates to: + /// **'Kickstart your learning journey today! Complete your daily goal to stay ahead.'** + String get qotdSubtitleNotStarted; + + /// No description provided for @qotdStatusCompleted. + /// + /// In en, this message translates to: + /// **'COMPLETED'** + String get qotdStatusCompleted; + + /// No description provided for @qotdStatusInProgress. + /// + /// In en, this message translates to: + /// **'IN PROGRESS'** + String get qotdStatusInProgress; + + /// No description provided for @qotdStatusNotStarted. + /// + /// In en, this message translates to: + /// **'NOT STARTED'** + String get qotdStatusNotStarted; + + /// No description provided for @qotdDifficulty. + /// + /// In en, this message translates to: + /// **'DIFFICULTY'** + String get qotdDifficulty; + + /// No description provided for @qotdTargetedSubjects. + /// + /// In en, this message translates to: + /// **'TARGETED SUBJECTS'** + String get qotdTargetedSubjects; + + /// No description provided for @qotdCorrect. + /// + /// In en, this message translates to: + /// **'Correct'** + String get qotdCorrect; + + /// No description provided for @qotdIncorrect. + /// + /// In en, this message translates to: + /// **'Incorrect'** + String get qotdIncorrect; + + /// No description provided for @qotdUnanswered. + /// + /// In en, this message translates to: + /// **'Unanswered'** + String get qotdUnanswered; + + /// No description provided for @qotdStartQuiz. + /// + /// In en, this message translates to: + /// **'Start Quiz'** + String get qotdStartQuiz; + + /// No description provided for @qotdResumeQuiz. + /// + /// In en, this message translates to: + /// **'Resume Quiz'** + String get qotdResumeQuiz; + + /// No description provided for @qotdViewSolutions. + /// + /// In en, this message translates to: + /// **'View Solutions'** + String get qotdViewSolutions; + + /// No description provided for @qotdMixed. + /// + /// In en, this message translates to: + /// **'Mixed'** + String get qotdMixed; + + /// No description provided for @qotdGeneral. + /// + /// In en, this message translates to: + /// **'General'** + String get qotdGeneral; + + /// No description provided for @qotdQuestionProgress. + /// + /// In en, this message translates to: + /// **'Question {current} of {total}'** + String qotdQuestionProgress(int current, int total); + + /// No description provided for @qotdPrevious. + /// + /// In en, this message translates to: + /// **'Previous'** + String get qotdPrevious; + + /// No description provided for @qotdNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get qotdNext; + + /// No description provided for @qotdCheck. + /// + /// In en, this message translates to: + /// **'Check'** + String get qotdCheck; + + /// No description provided for @qotdFinish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get qotdFinish; + + /// No description provided for @qotdProgressLabel. + /// + /// In en, this message translates to: + /// **'COMPLETED'** + String get qotdProgressLabel; + + /// No description provided for @qotdExplanationTitle. + /// + /// In en, this message translates to: + /// **'Explanation'** + String get qotdExplanationTitle; + + /// No description provided for @qotdNoExplanation. + /// + /// In en, this message translates to: + /// **'No explanation available'** + String get qotdNoExplanation; + + /// No description provided for @qotdEmptyStateTitle. + /// + /// In en, this message translates to: + /// **'No Daily Questions Available'** + String get qotdEmptyStateTitle; + + /// No description provided for @qotdEmptyStateBody. + /// + /// In en, this message translates to: + /// **'There are no questions configured for your account today. Please check back later to continue learning!'** + String get qotdEmptyStateBody; + + /// No description provided for @qotdBackToDashboard. + /// + /// In en, this message translates to: + /// **'Back to Dashboard'** + String get qotdBackToDashboard; + + /// No description provided for @qotdNextQuestion. + /// + /// In en, this message translates to: + /// **'Next question'** + String get qotdNextQuestion; + + /// No description provided for @qotdCheckAnswer. + /// + /// In en, this message translates to: + /// **'Check answer'** + String get qotdCheckAnswer; + + /// No description provided for @qotdOptionLabel. + /// + /// In en, this message translates to: + /// **'Option'** + String get qotdOptionLabel; + + /// No description provided for @qotdSingleCorrect. + /// + /// In en, this message translates to: + /// **'Single Correct'** + String get qotdSingleCorrect; + + /// No description provided for @qotdMultipleCorrect. + /// + /// In en, this message translates to: + /// **'Multiple Correct'** + String get qotdMultipleCorrect; + + /// No description provided for @qotdErrorSubmitAnswer. + /// + /// In en, this message translates to: + /// **'Failed to submit answer. Please try again.'** + String get qotdErrorSubmitAnswer; } class _AppLocalizationsDelegate diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 78884e8fa..e000a9351 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -3193,4 +3193,128 @@ class AppLocalizationsAr extends AppLocalizations { String announcementsSelectedFilter(String category) { return 'التصفية المحددة: $category. انقر للتغيير'; } + + @override + String get qotdTitle => 'الأسئلة اليومية'; + + @override + String get qotdEmptyState => 'لا توجد أسئلة متاحة اليوم.'; + + @override + String get qotdErrorFailedToLoad => 'فشل تحميل الأسئلة اليومية'; + + @override + String get qotdRetry => 'إعادة المحاولة'; + + @override + String qotdAttemptedCount(int attempted, int total) { + return 'تمت محاولة $attempted من أصل $total'; + } + + @override + String get qotdSubtitleCompleted => + 'عمل رائع! لقد تغلبت على تحدي اليوم. استمر في التقدم!'; + + @override + String qotdSubtitleInProgress(int remaining) { + return 'أنت على المسار الصحيح لإنهاء هدفك اليومي. تبقى $remaining أسئلة فقط!'; + } + + @override + String get qotdSubtitleNotStarted => + 'ابدأ رحلتك التعليمية اليوم! أكمل هدفك اليومي للبقاء في الصدارة.'; + + @override + String get qotdStatusCompleted => 'مكتمل'; + + @override + String get qotdStatusInProgress => 'قيد التقدم'; + + @override + String get qotdStatusNotStarted => 'لم يبدأ'; + + @override + String get qotdDifficulty => 'مستوى الصعوبة'; + + @override + String get qotdTargetedSubjects => 'المواضيع المستهدفة'; + + @override + String get qotdCorrect => 'صحيح'; + + @override + String get qotdIncorrect => 'غير صحيح'; + + @override + String get qotdUnanswered => 'لم تتم الإجابة'; + + @override + String get qotdStartQuiz => 'بدء الاختبار'; + + @override + String get qotdResumeQuiz => 'استئناف الاختبار'; + + @override + String get qotdViewSolutions => 'عرض الحلول'; + + @override + String get qotdMixed => 'مختلط'; + + @override + String get qotdGeneral => 'عام'; + + @override + String qotdQuestionProgress(int current, int total) { + return 'السؤال $current من $total'; + } + + @override + String get qotdPrevious => 'السابق'; + + @override + String get qotdNext => 'التالي'; + + @override + String get qotdCheck => 'تحقق'; + + @override + String get qotdFinish => 'إنهاء'; + + @override + String get qotdProgressLabel => 'مكتمل'; + + @override + String get qotdExplanationTitle => 'الشرح'; + + @override + String get qotdNoExplanation => 'لا يوجد شرح متاح'; + + @override + String get qotdEmptyStateTitle => 'لا توجد أسئلة يومية متاحة'; + + @override + String get qotdEmptyStateBody => + 'لا توجد أسئلة مُعدّة لحسابك اليوم. يرجى العودة لاحقاً لمواصلة التعلم!'; + + @override + String get qotdBackToDashboard => 'العودة إلى لوحة التحكم'; + + @override + String get qotdNextQuestion => 'السؤال التالي'; + + @override + String get qotdCheckAnswer => 'تحقق من الإجابة'; + + @override + String get qotdOptionLabel => 'خيار'; + + @override + String get qotdSingleCorrect => 'إجابة صحيحة واحدة'; + + @override + String get qotdMultipleCorrect => 'إجابات صحيحة متعددة'; + + @override + String get qotdErrorSubmitAnswer => + 'فشل إرسال الإجابة. يرجى المحاولة مرة أخرى.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 352d07a0d..1b963f351 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -3193,4 +3193,128 @@ class AppLocalizationsEn extends AppLocalizations { String announcementsSelectedFilter(String category) { return 'Selected filter: $category. Tap to change filter'; } + + @override + String get qotdTitle => 'Daily Questions'; + + @override + String get qotdEmptyState => 'No questions available today.'; + + @override + String get qotdErrorFailedToLoad => 'Failed to load daily questions'; + + @override + String get qotdRetry => 'Retry'; + + @override + String qotdAttemptedCount(int attempted, int total) { + return '$attempted / $total Attempted'; + } + + @override + String get qotdSubtitleCompleted => + 'Fantastic job! You\'ve conquered today\'s challenge. Keep up the momentum!'; + + @override + String qotdSubtitleInProgress(int remaining) { + return 'You\'re on track to finish your daily goal. Just $remaining more questions to go!'; + } + + @override + String get qotdSubtitleNotStarted => + 'Kickstart your learning journey today! Complete your daily goal to stay ahead.'; + + @override + String get qotdStatusCompleted => 'COMPLETED'; + + @override + String get qotdStatusInProgress => 'IN PROGRESS'; + + @override + String get qotdStatusNotStarted => 'NOT STARTED'; + + @override + String get qotdDifficulty => 'DIFFICULTY'; + + @override + String get qotdTargetedSubjects => 'TARGETED SUBJECTS'; + + @override + String get qotdCorrect => 'Correct'; + + @override + String get qotdIncorrect => 'Incorrect'; + + @override + String get qotdUnanswered => 'Unanswered'; + + @override + String get qotdStartQuiz => 'Start Quiz'; + + @override + String get qotdResumeQuiz => 'Resume Quiz'; + + @override + String get qotdViewSolutions => 'View Solutions'; + + @override + String get qotdMixed => 'Mixed'; + + @override + String get qotdGeneral => 'General'; + + @override + String qotdQuestionProgress(int current, int total) { + return 'Question $current of $total'; + } + + @override + String get qotdPrevious => 'Previous'; + + @override + String get qotdNext => 'Next'; + + @override + String get qotdCheck => 'Check'; + + @override + String get qotdFinish => 'Finish'; + + @override + String get qotdProgressLabel => 'COMPLETED'; + + @override + String get qotdExplanationTitle => 'Explanation'; + + @override + String get qotdNoExplanation => 'No explanation available'; + + @override + String get qotdEmptyStateTitle => 'No Daily Questions Available'; + + @override + String get qotdEmptyStateBody => + 'There are no questions configured for your account today. Please check back later to continue learning!'; + + @override + String get qotdBackToDashboard => 'Back to Dashboard'; + + @override + String get qotdNextQuestion => 'Next question'; + + @override + String get qotdCheckAnswer => 'Check answer'; + + @override + String get qotdOptionLabel => 'Option'; + + @override + String get qotdSingleCorrect => 'Single Correct'; + + @override + String get qotdMultipleCorrect => 'Multiple Correct'; + + @override + String get qotdErrorSubmitAnswer => + 'Failed to submit answer. Please try again.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index 7e797997a..bab4be01f 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -3247,4 +3247,129 @@ class AppLocalizationsMl extends AppLocalizations { String announcementsSelectedFilter(String category) { return 'തിരഞ്ഞെടുത്ത ഫിൽട്ടർ: $category. മാറ്റാൻ ടാപ്പ് ചെയ്യുക'; } + + @override + String get qotdTitle => 'ദിനചര്യ ചോദ്യങ്ങൾ'; + + @override + String get qotdEmptyState => 'ഇന്ന് ചോദ്യങ്ങളൊന്നും ലഭ്യമല്ല.'; + + @override + String get qotdErrorFailedToLoad => + 'ദിനചര്യ ചോദ്യങ്ങൾ ലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു'; + + @override + String get qotdRetry => 'വീണ്ടും ശ്രമിക്കുക'; + + @override + String qotdAttemptedCount(int attempted, int total) { + return '$attempted / $total പൂർത്തിയാക്കി'; + } + + @override + String get qotdSubtitleCompleted => + 'മികച്ച പ്രവർത്തനം! ഇന്നത്തെ വെല്ലുവിളി നിങ്ങൾ വിജയകരമായി പൂർത്തിയാക്കി. വേഗത നിലനിർത്തുക!'; + + @override + String qotdSubtitleInProgress(int remaining) { + return 'നിങ്ങളുടെ ദൈനംദിன ലക്ഷ്യം പൂർത്തിയാക്കാനുള്ള പാതയിലാണ് നിങ്ങൾ. ഇനി $remaining ചോദ്യങ്ങൾ കൂടി ബാക്കിയുണ്ട്!'; + } + + @override + String get qotdSubtitleNotStarted => + 'ഇന്ന് നിങ്ങളുടെ പഠന യാത്ര ആരംഭിക്കുക! മുന്നേറാൻ നിങ്ങളുടെ ദൈനംദിന ലക്ഷ്യം പൂർത്തിയാക്കുക.'; + + @override + String get qotdStatusCompleted => 'പൂർത്തിയായി'; + + @override + String get qotdStatusInProgress => 'പുരോഗതിയിൽ'; + + @override + String get qotdStatusNotStarted => 'ആരംഭിച്ചിട്ടില്ല'; + + @override + String get qotdDifficulty => 'കാഠിന്യം'; + + @override + String get qotdTargetedSubjects => 'ലക്ഷ്യമിട്ട വിഷയങ്ങൾ'; + + @override + String get qotdCorrect => 'ശരി'; + + @override + String get qotdIncorrect => 'തെറ്റ്'; + + @override + String get qotdUnanswered => 'ഉത്തരം നൽകാത്തവ'; + + @override + String get qotdStartQuiz => 'ക്വിസ് ആരംഭിക്കുക'; + + @override + String get qotdResumeQuiz => 'ക്വിസ് തുടരുക'; + + @override + String get qotdViewSolutions => 'പരിഹാരങ്ങൾ കാണുക'; + + @override + String get qotdMixed => 'മിശ്രിതം'; + + @override + String get qotdGeneral => 'പൊതുവായത്'; + + @override + String qotdQuestionProgress(int current, int total) { + return 'ചോദ്യം $current / $total'; + } + + @override + String get qotdPrevious => 'മുമ്പത്തേത്'; + + @override + String get qotdNext => 'അടുത്തത്'; + + @override + String get qotdCheck => 'പരിശോധിക്കുക'; + + @override + String get qotdFinish => 'പൂർത്തിയാക്കുക'; + + @override + String get qotdProgressLabel => 'പൂർത്തിയായി'; + + @override + String get qotdExplanationTitle => 'വിശദീകരണം'; + + @override + String get qotdNoExplanation => 'വിശദീകരണമൊന്നും ലഭ്യമല്ല'; + + @override + String get qotdEmptyStateTitle => 'ഇന്ന് ദൈനംദിന ചോദ്യങ്ങൾ ലഭ്യമല്ല'; + + @override + String get qotdEmptyStateBody => + 'നിങ്ങളുടെ അക്കൗണ്ടിനായി ഇന്ന് ചോദ്യങ്ങൾ ക്രമീകരിച്ചിട്ടില്ല. പഠനം തുടരാൻ പിന്നീട് വരൂ!'; + + @override + String get qotdBackToDashboard => 'ഡാഷ്‌ബോർഡിലേക്ക് മടങ്ങുക'; + + @override + String get qotdNextQuestion => 'അടുത്ത ചോദ്യം'; + + @override + String get qotdCheckAnswer => 'ഉത്തരം പരിശോധിക്കുക'; + + @override + String get qotdOptionLabel => 'ഓപ്ഷൻ'; + + @override + String get qotdSingleCorrect => 'ഒറ്റ ശരിയുത്തരം'; + + @override + String get qotdMultipleCorrect => 'ഒന്നിലധികം ശരിയുത്തരങ്ങൾ'; + + @override + String get qotdErrorSubmitAnswer => + 'ഉത്തരം സമർപ്പിക്കുന്നതിൽ പരാജയപ്പെട്ടു. ദയവായി വീണ്ടും ശ്രമിക്കുക.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index 7a7e049ff..e64f6edbe 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -3243,4 +3243,128 @@ class AppLocalizationsTa extends AppLocalizations { String announcementsSelectedFilter(String category) { return 'தேர்ந்தெடுக்கப்பட்ட வடிப்பான்: $category. மாற்ற தட்டவும்'; } + + @override + String get qotdTitle => 'தினசரி கேள்விகள்'; + + @override + String get qotdEmptyState => 'இன்று கேள்விகள் எதுவும் கிடைக்கவில்லை.'; + + @override + String get qotdErrorFailedToLoad => 'தினசரி கேள்விகளை ஏற்றுவதில் தோல்வி'; + + @override + String get qotdRetry => 'மீண்டும் முயற்சி செய்'; + + @override + String qotdAttemptedCount(int attempted, int total) { + return '$attempted / $total முயற்சிக்கப்பட்டது'; + } + + @override + String get qotdSubtitleCompleted => + 'அருமையான வேலை! இன்றைய சவாலை நீங்கள் வென்றுவிட்டீர்கள். தொடர்ந்து முன்னேறுங்கள்!'; + + @override + String qotdSubtitleInProgress(int remaining) { + return 'உங்கள் தினசரி இலக்கை முடிக்க சரியான பாதையில் உள்ளீர்கள். இன்னும் $remaining கேள்விகள் மட்டுமே உள்ளன!'; + } + + @override + String get qotdSubtitleNotStarted => + 'இன்றே உங்கள் கற்றல் பயணத்தைத் தொடங்குங்கள்! முன்னிலையில் இருக்க உங்கள் தினசரி இலக்கை முடிக்கவும்.'; + + @override + String get qotdStatusCompleted => 'முடிந்தது'; + + @override + String get qotdStatusInProgress => 'செயல்பாட்டில் உள்ளது'; + + @override + String get qotdStatusNotStarted => 'தொடங்கப்படவில்லை'; + + @override + String get qotdDifficulty => 'கடினத்தன்மை'; + + @override + String get qotdTargetedSubjects => 'குறிப்பிடப்பட்ட பாடங்கள்'; + + @override + String get qotdCorrect => 'சரி'; + + @override + String get qotdIncorrect => 'தவறு'; + + @override + String get qotdUnanswered => 'பதிலளிக்கப்படவில்லை'; + + @override + String get qotdStartQuiz => 'வினாடி வினாவைத் தொடங்கு'; + + @override + String get qotdResumeQuiz => 'வினாடி வினாவைத் தொடரவும்'; + + @override + String get qotdViewSolutions => 'விடைகளைப் பார்க்கவும்'; + + @override + String get qotdMixed => 'கலவை'; + + @override + String get qotdGeneral => 'பொதுவானது'; + + @override + String qotdQuestionProgress(int current, int total) { + return 'கேள்வி $current / $total'; + } + + @override + String get qotdPrevious => 'முந்தையது'; + + @override + String get qotdNext => 'அடுத்தது'; + + @override + String get qotdCheck => 'சரிபார்க்கவும்'; + + @override + String get qotdFinish => 'முடிக்கவும்'; + + @override + String get qotdProgressLabel => 'முடிந்தது'; + + @override + String get qotdExplanationTitle => 'விளக்கம்'; + + @override + String get qotdNoExplanation => 'விளக்கம் எதுவும் கிடைக்கவில்லை'; + + @override + String get qotdEmptyStateTitle => 'இன்று தினசரி கேள்விகள் இல்லை'; + + @override + String get qotdEmptyStateBody => + 'உங்கள் கணக்கிற்காக இன்று கேள்விகள் அமைக்கப்படவில்லை. தொடர்ந்து கற்க பின்னர் வாருங்கள்!'; + + @override + String get qotdBackToDashboard => 'டாஷ்போர்டுக்கு திரும்பு'; + + @override + String get qotdNextQuestion => 'அடுத்த கேள்வி'; + + @override + String get qotdCheckAnswer => 'பதிலை சரிபார்'; + + @override + String get qotdOptionLabel => 'விருப்பம்'; + + @override + String get qotdSingleCorrect => 'ஒரே சரியான விடை'; + + @override + String get qotdMultipleCorrect => 'பல சரியான விடைகள்'; + + @override + String get qotdErrorSubmitAnswer => + 'பதிலைச் சமர்ப்பிப்பதில் தோல்வி. மீண்டும் முயற்சிக்கவும்.'; } diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 50f3a6191..84cef0004 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -1206,5 +1206,43 @@ "announcementsCloseFilter": "إغلاق تصفية الفئات", "announcementsFilterAction": "تصفية الفئات", "announcementsClearCategoryFilter": "مسح تصفية {category}", - "announcementsSelectedFilter": "التصفية المحددة: {category}. انقر للتغيير" + "announcementsSelectedFilter": "التصفية المحددة: {category}. انقر للتغيير", + "qotdTitle": "الأسئلة اليومية", + "qotdEmptyState": "لا توجد أسئلة متاحة اليوم.", + "qotdErrorFailedToLoad": "فشل تحميل الأسئلة اليومية", + "qotdRetry": "إعادة المحاولة", + "qotdAttemptedCount": "تمت محاولة {attempted} من أصل {total}", + "qotdSubtitleCompleted": "عمل رائع! لقد تغلبت على تحدي اليوم. استمر في التقدم!", + "qotdSubtitleInProgress": "أنت على المسار الصحيح لإنهاء هدفك اليومي. تبقى {remaining} أسئلة فقط!", + "qotdSubtitleNotStarted": "ابدأ رحلتك التعليمية اليوم! أكمل هدفك اليومي للبقاء في الصدارة.", + "qotdStatusCompleted": "مكتمل", + "qotdStatusInProgress": "قيد التقدم", + "qotdStatusNotStarted": "لم يبدأ", + "qotdDifficulty": "مستوى الصعوبة", + "qotdTargetedSubjects": "المواضيع المستهدفة", + "qotdCorrect": "صحيح", + "qotdIncorrect": "غير صحيح", + "qotdUnanswered": "لم تتم الإجابة", + "qotdStartQuiz": "بدء الاختبار", + "qotdResumeQuiz": "استئناف الاختبار", + "qotdViewSolutions": "عرض الحلول", + "qotdMixed": "مختلط", + "qotdGeneral": "عام", + "qotdQuestionProgress": "السؤال {current} من {total}", + "qotdPrevious": "السابق", + "qotdNext": "التالي", + "qotdCheck": "تحقق", + "qotdFinish": "إنهاء", + "qotdProgressLabel": "مكتمل", + "qotdExplanationTitle": "الشرح", + "qotdNoExplanation": "لا يوجد شرح متاح", + "qotdEmptyStateTitle": "لا توجد أسئلة يومية متاحة", + "qotdEmptyStateBody": "لا توجد أسئلة مُعدّة لحسابك اليوم. يرجى العودة لاحقاً لمواصلة التعلم!", + "qotdBackToDashboard": "العودة إلى لوحة التحكم", + "qotdNextQuestion": "السؤال التالي", + "qotdCheckAnswer": "تحقق من الإجابة", + "qotdOptionLabel": "خيار", + "qotdSingleCorrect": "إجابة صحيحة واحدة", + "qotdMultipleCorrect": "إجابات صحيحة متعددة", + "qotdErrorSubmitAnswer": "فشل إرسال الإجابة. يرجى المحاولة مرة أخرى." } diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index 7239d2bbf..2a01499e1 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -1587,5 +1587,70 @@ "type": "String" } } - } + }, + "qotdTitle": "Daily Questions", + "qotdEmptyState": "No questions available today.", + "qotdErrorFailedToLoad": "Failed to load daily questions", + "qotdRetry": "Retry", + "qotdAttemptedCount": "{attempted} / {total} Attempted", + "@qotdAttemptedCount": { + "placeholders": { + "attempted": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "qotdSubtitleCompleted": "Fantastic job! You've conquered today's challenge. Keep up the momentum!", + "qotdSubtitleInProgress": "You're on track to finish your daily goal. Just {remaining} more questions to go!", + "@qotdSubtitleInProgress": { + "placeholders": { + "remaining": { + "type": "int" + } + } + }, + "qotdSubtitleNotStarted": "Kickstart your learning journey today! Complete your daily goal to stay ahead.", + "qotdStatusCompleted": "COMPLETED", + "qotdStatusInProgress": "IN PROGRESS", + "qotdStatusNotStarted": "NOT STARTED", + "qotdDifficulty": "DIFFICULTY", + "qotdTargetedSubjects": "TARGETED SUBJECTS", + "qotdCorrect": "Correct", + "qotdIncorrect": "Incorrect", + "qotdUnanswered": "Unanswered", + "qotdStartQuiz": "Start Quiz", + "qotdResumeQuiz": "Resume Quiz", + "qotdViewSolutions": "View Solutions", + "qotdMixed": "Mixed", + "qotdGeneral": "General", + "qotdQuestionProgress": "Question {current} of {total}", + "@qotdQuestionProgress": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "qotdPrevious": "Previous", + "qotdNext": "Next", + "qotdCheck": "Check", + "qotdFinish": "Finish", + "qotdProgressLabel": "COMPLETED", + "qotdExplanationTitle": "Explanation", + "qotdNoExplanation": "No explanation available", + "qotdEmptyStateTitle": "No Daily Questions Available", + "qotdEmptyStateBody": "There are no questions configured for your account today. Please check back later to continue learning!", + "qotdBackToDashboard": "Back to Dashboard", + "qotdNextQuestion": "Next question", + "qotdCheckAnswer": "Check answer", + "qotdOptionLabel": "Option", + "qotdSingleCorrect": "Single Correct", + "qotdMultipleCorrect": "Multiple Correct", + "qotdErrorSubmitAnswer": "Failed to submit answer. Please try again." } diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index ebb2b94d3..336068442 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -1206,5 +1206,43 @@ "announcementsCloseFilter": "വിഭാഗം ഫിൽട്ടർ അടയ്ക്കുക", "announcementsFilterAction": "വിഭാഗങ്ങൾ ഫിൽട്ടർ ചെയ്യുക", "announcementsClearCategoryFilter": "{category} ഫിൽട്ടർ നീക്കം ചെയ്യുക", - "announcementsSelectedFilter": "തിരഞ്ഞെടുത്ത ഫിൽട്ടർ: {category}. മാറ്റാൻ ടാപ്പ് ചെയ്യുക" + "announcementsSelectedFilter": "തിരഞ്ഞെടുത്ത ഫിൽട്ടർ: {category}. മാറ്റാൻ ടാപ്പ് ചെയ്യുക", + "qotdTitle": "ദിനചര്യ ചോദ്യങ്ങൾ", + "qotdEmptyState": "ഇന്ന് ചോദ്യങ്ങളൊന്നും ലഭ്യമല്ല.", + "qotdErrorFailedToLoad": "ദിനചര്യ ചോദ്യങ്ങൾ ലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", + "qotdRetry": "വീണ്ടും ശ്രമിക്കുക", + "qotdAttemptedCount": "{attempted} / {total} പൂർത്തിയാക്കി", + "qotdSubtitleCompleted": "മികച്ച പ്രവർത്തനം! ഇന്നത്തെ വെല്ലുവിളി നിങ്ങൾ വിജയകരമായി പൂർത്തിയാക്കി. വേഗത നിലനിർത്തുക!", + "qotdSubtitleInProgress": "നിങ്ങളുടെ ദൈനംദിன ലക്ഷ്യം പൂർത്തിയാക്കാനുള്ള പാതയിലാണ് നിങ്ങൾ. ഇനി {remaining} ചോദ്യങ്ങൾ കൂടി ബാക്കിയുണ്ട്!", + "qotdSubtitleNotStarted": "ഇന്ന് നിങ്ങളുടെ പഠന യാത്ര ആരംഭിക്കുക! മുന്നേറാൻ നിങ്ങളുടെ ദൈനംദിന ലക്ഷ്യം പൂർത്തിയാക്കുക.", + "qotdStatusCompleted": "പൂർത്തിയായി", + "qotdStatusInProgress": "പുരോഗതിയിൽ", + "qotdStatusNotStarted": "ആരംഭിച്ചിട്ടില്ല", + "qotdDifficulty": "കാഠിന്യം", + "qotdTargetedSubjects": "ലക്ഷ്യമിട്ട വിഷയങ്ങൾ", + "qotdCorrect": "ശരി", + "qotdIncorrect": "തെറ്റ്", + "qotdUnanswered": "ഉത്തരം നൽകാത്തവ", + "qotdStartQuiz": "ക്വിസ് ആരംഭിക്കുക", + "qotdResumeQuiz": "ക്വിസ് തുടരുക", + "qotdViewSolutions": "പരിഹാരങ്ങൾ കാണുക", + "qotdMixed": "മിശ്രിതം", + "qotdGeneral": "പൊതുവായത്", + "qotdQuestionProgress": "ചോദ്യം {current} / {total}", + "qotdPrevious": "മുമ്പത്തേത്", + "qotdNext": "അടുത്തത്", + "qotdCheck": "പരിശോധിക്കുക", + "qotdFinish": "പൂർത്തിയാക്കുക", + "qotdProgressLabel": "പൂർത്തിയായി", + "qotdExplanationTitle": "വിശദീകരണം", + "qotdNoExplanation": "വിശദീകരണമൊന്നും ലഭ്യമല്ല", + "qotdEmptyStateTitle": "ഇന്ന് ദൈനംദിന ചോദ്യങ്ങൾ ലഭ്യമല്ല", + "qotdEmptyStateBody": "നിങ്ങളുടെ അക്കൗണ്ടിനായി ഇന്ന് ചോദ്യങ്ങൾ ക്രമീകരിച്ചിട്ടില്ല. പഠനം തുടരാൻ പിന്നീട് വരൂ!", + "qotdBackToDashboard": "ഡാഷ്‌ബോർഡിലേക്ക് മടങ്ങുക", + "qotdNextQuestion": "അടുത്ത ചോദ്യം", + "qotdCheckAnswer": "ഉത്തരം പരിശോധിക്കുക", + "qotdOptionLabel": "ഓപ്ഷൻ", + "qotdSingleCorrect": "ഒറ്റ ശരിയുത്തരം", + "qotdMultipleCorrect": "ഒന്നിലധികം ശരിയുത്തരങ്ങൾ", + "qotdErrorSubmitAnswer": "ഉത്തരം സമർപ്പിക്കുന്നതിൽ പരാജയപ്പെട്ടു. ദയവായി വീണ്ടും ശ്രമിക്കുക." } diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index b7544f5b1..67c0c2b06 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -1447,5 +1447,43 @@ "announcementsCloseFilter": "வகை வடிப்பானை மூடு", "announcementsFilterAction": "வகைகளை வடிகட்டு", "announcementsClearCategoryFilter": "{category} வடிப்பானை நீக்கு", - "announcementsSelectedFilter": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்: {category}. மாற்ற தட்டவும்" + "announcementsSelectedFilter": "தேர்ந்தெடுக்கப்பட்ட வடிப்பான்: {category}. மாற்ற தட்டவும்", + "qotdTitle": "தினசரி கேள்விகள்", + "qotdEmptyState": "இன்று கேள்விகள் எதுவும் கிடைக்கவில்லை.", + "qotdErrorFailedToLoad": "தினசரி கேள்விகளை ஏற்றுவதில் தோல்வி", + "qotdRetry": "மீண்டும் முயற்சி செய்", + "qotdAttemptedCount": "{attempted} / {total} முயற்சிக்கப்பட்டது", + "qotdSubtitleCompleted": "அருமையான வேலை! இன்றைய சவாலை நீங்கள் வென்றுவிட்டீர்கள். தொடர்ந்து முன்னேறுங்கள்!", + "qotdSubtitleInProgress": "உங்கள் தினசரி இலக்கை முடிக்க சரியான பாதையில் உள்ளீர்கள். இன்னும் {remaining} கேள்விகள் மட்டுமே உள்ளன!", + "qotdSubtitleNotStarted": "இன்றே உங்கள் கற்றல் பயணத்தைத் தொடங்குங்கள்! முன்னிலையில் இருக்க உங்கள் தினசரி இலக்கை முடிக்கவும்.", + "qotdStatusCompleted": "முடிந்தது", + "qotdStatusInProgress": "செயல்பாட்டில் உள்ளது", + "qotdStatusNotStarted": "தொடங்கப்படவில்லை", + "qotdDifficulty": "கடினத்தன்மை", + "qotdTargetedSubjects": "குறிப்பிடப்பட்ட பாடங்கள்", + "qotdCorrect": "சரி", + "qotdIncorrect": "தவறு", + "qotdUnanswered": "பதிலளிக்கப்படவில்லை", + "qotdStartQuiz": "வினாடி வினாவைத் தொடங்கு", + "qotdResumeQuiz": "வினாடி வினாவைத் தொடரவும்", + "qotdViewSolutions": "விடைகளைப் பார்க்கவும்", + "qotdMixed": "கலவை", + "qotdGeneral": "பொதுவானது", + "qotdQuestionProgress": "கேள்வி {current} / {total}", + "qotdPrevious": "முந்தையது", + "qotdNext": "அடுத்தது", + "qotdCheck": "சரிபார்க்கவும்", + "qotdFinish": "முடிக்கவும்", + "qotdProgressLabel": "முடிந்தது", + "qotdExplanationTitle": "விளக்கம்", + "qotdNoExplanation": "விளக்கம் எதுவும் கிடைக்கவில்லை", + "qotdEmptyStateTitle": "இன்று தினசரி கேள்விகள் இல்லை", + "qotdEmptyStateBody": "உங்கள் கணக்கிற்காக இன்று கேள்விகள் அமைக்கப்படவில்லை. தொடர்ந்து கற்க பின்னர் வாருங்கள்!", + "qotdBackToDashboard": "டாஷ்போர்டுக்கு திரும்பு", + "qotdNextQuestion": "அடுத்த கேள்வி", + "qotdCheckAnswer": "பதிலை சரிபார்", + "qotdOptionLabel": "விருப்பம்", + "qotdSingleCorrect": "ஒரே சரியான விடை", + "qotdMultipleCorrect": "பல சரியான விடைகள்", + "qotdErrorSubmitAnswer": "பதிலைச் சமர்ப்பிப்பதில் தோல்வி. மீண்டும் முயற்சிக்கவும்." } diff --git a/packages/core/lib/widgets/app_html_v2.dart b/packages/core/lib/widgets/app_html_v2.dart index df7826c84..6a87bcfd0 100644 --- a/packages/core/lib/widgets/app_html_v2.dart +++ b/packages/core/lib/widgets/app_html_v2.dart @@ -1,11 +1,14 @@ +import 'dart:convert'; import 'dart:io'; import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../design/design_provider.dart'; import '../design/design_config.dart'; +import '../data/config/app_config.dart'; import 'app_loading_indicator.dart'; /// Native HTML + LaTeX renderer. @@ -29,6 +32,7 @@ class AppHtmlV2 extends StatelessWidget { this.padding = EdgeInsets.zero, this.maxLines, this.disableImageZoom = false, + this.baseUrl, }); final String data; @@ -41,6 +45,11 @@ class AppHtmlV2 extends StatelessWidget { final int? maxLines; final bool disableImageZoom; + /// Optional base URL used to resolve relative image/link paths in HTML. + /// Only pass this when the HTML content originates from a remote source + /// that uses relative URLs (e.g. QOTD questions from the API). + final Uri? baseUrl; + String _sanitizeHtml(String html) { var res = html; @@ -138,6 +147,7 @@ class AppHtmlV2 extends StatelessWidget { padding: padding, child: HtmlWidget( processedData, + baseUrl: baseUrl, onTapImage: disableImageZoom ? null @@ -156,6 +166,19 @@ class AppHtmlV2 extends StatelessWidget { maxWidth: MediaQuery.of(context).size.width - 64, ), + onErrorBuilder: (context, element, error) => Container( + padding: EdgeInsets.all(design.spacing.xs), + decoration: BoxDecoration( + color: design.colors.surfaceVariant, + borderRadius: BorderRadius.circular(design.radius.sm), + ), + child: Icon( + LucideIcons.imageOff, + size: 16, + color: design.colors.textTertiary, + ), + ), + onLoadingBuilder: (context, element, progress) { final widthAttr = element.attributes['width']; final heightAttr = element.attributes['height']; @@ -438,6 +461,22 @@ class _MathWidgetFactory extends WidgetFactory { @override void parse(BuildTree meta) { + if (meta.element.localName == 'img') { + final src = meta.element.attributes['src']; + if (src != null && + (src.toLowerCase().contains('.svg') || + src.startsWith('data:image/svg+xml'))) { + meta.register( + BuildOp.inline( + onRenderInlineBlock: (tree, child) { + return _buildSvg(src); + }, + ), + ); + return; + } + } + if (meta.element.localName == 'math-tex') { final isBlock = meta.element.attributes['block'] == 'true'; @@ -547,6 +586,46 @@ class _MathWidgetFactory extends WidgetFactory { ), ); } + + Widget _buildSvg(String src) { + try { + if (src.startsWith('data:image/svg+xml;base64,')) { + final raw = src.substring('data:image/svg+xml;base64,'.length); + return SvgPicture.memory( + base64Decode(raw), + placeholderBuilder: (_) => const SizedBox.shrink(), + ); + } else if (src.startsWith('data:image/svg+xml;utf8,') || + src.startsWith('data:image/svg+xml,')) { + final prefix = src.startsWith('data:image/svg+xml;utf8,') + ? 'data:image/svg+xml;utf8,' + : 'data:image/svg+xml,'; + final raw = Uri.decodeComponent(src.substring(prefix.length)); + return SvgPicture.string( + raw, + placeholderBuilder: (_) => const SizedBox.shrink(), + ); + } else { + String fullUrl = src; + if (!src.startsWith('http://') && !src.startsWith('https://')) { + final base = AppConfig.apiBaseUrl.endsWith('/') + ? AppConfig.apiBaseUrl.substring( + 0, + AppConfig.apiBaseUrl.length - 1, + ) + : AppConfig.apiBaseUrl; + final path = src.startsWith('/') ? src : '/$src'; + fullUrl = '$base$path'; + } + return SvgPicture.network( + fullUrl, + placeholderBuilder: (_) => const SizedBox.shrink(), + ); + } + } catch (_) { + return const SizedBox.shrink(); + } + } } class _ZoomableImageViewer extends StatelessWidget { diff --git a/packages/exams/lib/screens/review_analytics/widgets/donut_chart.dart b/packages/core/lib/widgets/donut_chart.dart similarity index 96% rename from packages/exams/lib/screens/review_analytics/widgets/donut_chart.dart rename to packages/core/lib/widgets/donut_chart.dart index 2f522c6cf..3ffe80494 100644 --- a/packages/exams/lib/screens/review_analytics/widgets/donut_chart.dart +++ b/packages/core/lib/widgets/donut_chart.dart @@ -1,8 +1,9 @@ import 'dart:math' as math; -import 'package:core/core.dart'; import 'package:flutter/widgets.dart'; +import '../design/design_provider.dart'; +/// A platform-neutral donut chart primitive for displaying categorical ratios. class DonutChart extends StatelessWidget { const DonutChart({ super.key, diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 9b21ed6db..ac5112444 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: collection: ^1.19.1 flutter_widget_from_html_core: ^0.17.2 flutter_math_fork: ^0.7.4 + flutter_svg: ^2.0.17 media_scanner: ^2.2.0 workmanager: ^0.9.0 connectivity_plus: ^6.1.0 diff --git a/packages/core/test/data/models/qotd_dto_test.dart b/packages/core/test/data/models/qotd_dto_test.dart new file mode 100644 index 000000000..7426c6a20 --- /dev/null +++ b/packages/core/test/data/models/qotd_dto_test.dart @@ -0,0 +1,222 @@ +import 'package:core/data/models/qotd_dto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('QotdQuestionType.from', () { + test('returns singleCorrect for null, empty, or single types', () { + expect(QotdQuestionType.from(null), QotdQuestionType.singleCorrect); + expect(QotdQuestionType.from(''), QotdQuestionType.singleCorrect); + expect(QotdQuestionType.from(' '), QotdQuestionType.singleCorrect); + expect(QotdQuestionType.from('S'), QotdQuestionType.singleCorrect); + expect(QotdQuestionType.from('SINGLE'), QotdQuestionType.singleCorrect); + expect( + QotdQuestionType.from('SINGLE_CHOICE'), + QotdQuestionType.singleCorrect, + ); + }); + + test('returns multipleCorrect for multi-choice codes and variations', () { + expect(QotdQuestionType.from('C'), QotdQuestionType.multipleCorrect); + expect(QotdQuestionType.from('M'), QotdQuestionType.multipleCorrect); + expect(QotdQuestionType.from('MCA'), QotdQuestionType.multipleCorrect); + expect( + QotdQuestionType.from('MULTIPLE'), + QotdQuestionType.multipleCorrect, + ); + expect( + QotdQuestionType.from('MULTIPLE_TYPE'), + QotdQuestionType.multipleCorrect, + ); + expect( + QotdQuestionType.from('MULTIPLESELECT'), + QotdQuestionType.multipleCorrect, + ); + expect( + QotdQuestionType.from('MULTIPLE_CHOICE'), + QotdQuestionType.multipleCorrect, + ); + expect( + QotdQuestionType.from('Multiple Choice'), + QotdQuestionType.multipleCorrect, + ); + expect( + QotdQuestionType.from('Multiple Correct'), + QotdQuestionType.multipleCorrect, + ); + }); + }); + + group('QotdDto.fromJson', () { + test('parses flat JSON payload correctly', () { + final json = { + 'id': 101, + 'question_id': 202, + 'question_html': '

What is the capital of France?

', + 'subject': 'Geography', + 'difficulty': 'Easy', + 'type': 'S', + 'options': [ + {'id': 1, 'text_html': '

Paris

'}, + {'id': 2, 'text_html': '

London

'}, + ], + }; + + final dto = QotdDto.fromJson(json); + + expect(dto.id, 101); + expect(dto.questionId, 202); + expect(dto.htmlContent, '

What is the capital of France?

'); + expect(dto.subject, 'Geography'); + expect(dto.difficulty, 'Easy'); + expect(dto.type, 'S'); + expect(dto.questionType, QotdQuestionType.singleCorrect); + expect(dto.options.length, 2); + expect(dto.options.first.id, 1); + expect(dto.options.first.htmlContent, '

Paris

'); + expect(dto.pastAttempt, isNull); + }); + + test('parses nested question JSON structure with key fallbacks', () { + final json = { + 'daily_question_id': 301, + 'question': { + 'id': 402, + 'text': '

Select all prime numbers

', + 'subject_name': 'Mathematics', + 'difficulty_level': 'Hard', + 'question_type': 'MCA', + 'answers': [ + {'id': 10, 'content': '2'}, + {'id': 11, 'text': '3'}, + {'id': 12, 'text_html': '4'}, + ], + }, + }; + + final dto = QotdDto.fromJson(json); + + expect(dto.id, 301); + expect(dto.questionId, 402); + expect(dto.htmlContent, '

Select all prime numbers

'); + expect(dto.subject, 'Mathematics'); + expect(dto.difficulty, 'Hard'); + expect(dto.questionType, QotdQuestionType.multipleCorrect); + expect(dto.options.length, 3); + expect(dto.options[0].htmlContent, '2'); + expect(dto.options[1].htmlContent, '3'); + expect(dto.options[2].htmlContent, '4'); + }); + + test('handles map-based subject and null/empty subject strings', () { + final mapSubjectJson = { + 'id': 1, + 'text': 'Question text', + 'subject': {'name': 'Physics'}, + }; + expect(QotdDto.fromJson(mapSubjectJson).subject, 'Physics'); + + final mapSubjectTitleJson = { + 'id': 1, + 'text': 'Question text', + 'subject': {'title': 'Biology'}, + }; + expect(QotdDto.fromJson(mapSubjectTitleJson).subject, 'Biology'); + + final nullSubjectJson = {'id': 1, 'text': 'Question text'}; + expect(QotdDto.fromJson(nullSubjectJson).subject, isNull); + + final whitespaceSubjectJson = { + 'id': 1, + 'text': 'Question text', + 'subject': ' ', + }; + expect(QotdDto.fromJson(whitespaceSubjectJson).subject, isNull); + }); + + test('parses past attempt when present in root or nested question', () { + final withRootAttempt = { + 'id': 1, + 'text': 'Sample Question', + 'attempt': { + 'is_correct': true, + 'explanation': 'Because of reason X', + 'selected_answer_ids': [101], + 'correct_answer_ids': [101], + }, + }; + + final dto = QotdDto.fromJson(withRootAttempt); + expect(dto.pastAttempt, isNotNull); + expect(dto.pastAttempt!.isCorrect, isTrue); + expect(dto.pastAttempt!.explanation, 'Because of reason X'); + expect(dto.pastAttempt!.selectedAnswerIds, [101]); + expect(dto.pastAttempt!.correctAnswerIds, [101]); + + final withNestedAttempt = { + 'id': 1, + 'question': { + 'text': 'Nested Sample', + 'attempt': { + 'is_correct': false, + 'explanation': 'Wrong answer', + 'answer_ids': [102], + 'correct_answer_ids': [101], + }, + }, + }; + + final nestedDto = QotdDto.fromJson(withNestedAttempt); + expect(nestedDto.pastAttempt, isNotNull); + expect(nestedDto.pastAttempt!.isCorrect, isFalse); + expect(nestedDto.pastAttempt!.selectedAnswerIds, [102]); + }); + }); + + group('QotdSubmitResponseDto.fromJson', () { + test('parses single answer_id integer into selectedAnswerIds list', () { + final json = { + 'is_correct': false, + 'explanation': 'Test explanation', + 'answer_id': 55, + 'correct_answer_ids': [60], + }; + + final response = QotdSubmitResponseDto.fromJson(json); + expect(response.isCorrect, isFalse); + expect(response.explanation, 'Test explanation'); + expect(response.selectedAnswerIds, [55]); + expect(response.correctAnswerIds, [60]); + }); + }); + + group('QotdSummaryDto.fromJson', () { + test('parses summary counts correctly', () { + final json = { + 'total_count': 10, + 'attempted_count': 6, + 'correct_count': 4, + 'incorrect_count': 2, + 'unanswered_count': 4, + 'status': 'in_progress', + }; + + final summary = QotdSummaryDto.fromJson(json); + expect(summary.totalCount, 10); + expect(summary.attemptedCount, 6); + expect(summary.correctCount, 4); + expect(summary.incorrectCount, 2); + expect(summary.unansweredCount, 4); + expect(summary.status, 'in_progress'); + }); + + test('defaults to zero when fields are missing', () { + final summary = QotdSummaryDto.fromJson({}); + expect(summary.totalCount, 0); + expect(summary.attemptedCount, 0); + expect(summary.correctCount, 0); + expect(summary.incorrectCount, 0); + expect(summary.unansweredCount, 0); + expect(summary.status, isNull); + }); + }); +} diff --git a/packages/core/test/data/repositories/user_repository_test.mocks.dart b/packages/core/test/data/repositories/user_repository_test.mocks.dart index 4616efef8..bb3f2adce 100644 --- a/packages/core/test/data/repositories/user_repository_test.mocks.dart +++ b/packages/core/test/data/repositories/user_repository_test.mocks.dart @@ -138,6 +138,18 @@ class _FakeBookmarkDto_20 extends _i1.SmartFake implements _i2.BookmarkDto { : super(parent, parentInvocation); } +class _FakeQotdSubmitResponseDto_21 extends _i1.SmartFake + implements _i2.QotdSubmitResponseDto { + _FakeQotdSubmitResponseDto_21(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeQotdSummaryDto_22 extends _i1.SmartFake + implements _i2.QotdSummaryDto { + _FakeQotdSummaryDto_22(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [DataSource]. /// /// See the documentation for Mockito's code generation for more information. @@ -1868,4 +1880,60 @@ class MockMockitoDataSource extends _i1.Mock implements _i2.DataSource { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future> getQotdQuestions() => + (super.noSuchMethod( + Invocation.method(#getQotdQuestions, []), + returnValue: _i3.Future>.value(<_i2.QotdDto>[]), + returnValueForMissingStub: _i3.Future>.value( + <_i2.QotdDto>[], + ), + ) + as _i3.Future>); + + @override + _i3.Future<_i2.QotdSubmitResponseDto> submitQotdAttempt( + int? questionId, + List? optionIds, + ) => + (super.noSuchMethod( + Invocation.method(#submitQotdAttempt, [questionId, optionIds]), + returnValue: _i3.Future<_i2.QotdSubmitResponseDto>.value( + _FakeQotdSubmitResponseDto_21( + this, + Invocation.method(#submitQotdAttempt, [questionId, optionIds]), + ), + ), + returnValueForMissingStub: + _i3.Future<_i2.QotdSubmitResponseDto>.value( + _FakeQotdSubmitResponseDto_21( + this, + Invocation.method(#submitQotdAttempt, [ + questionId, + optionIds, + ]), + ), + ), + ) + as _i3.Future<_i2.QotdSubmitResponseDto>); + + @override + _i3.Future<_i2.QotdSummaryDto> getQotdSummary() => + (super.noSuchMethod( + Invocation.method(#getQotdSummary, []), + returnValue: _i3.Future<_i2.QotdSummaryDto>.value( + _FakeQotdSummaryDto_22( + this, + Invocation.method(#getQotdSummary, []), + ), + ), + returnValueForMissingStub: _i3.Future<_i2.QotdSummaryDto>.value( + _FakeQotdSummaryDto_22( + this, + Invocation.method(#getQotdSummary, []), + ), + ), + ) + as _i3.Future<_i2.QotdSummaryDto>); } diff --git a/packages/core/test/data/services/offline_exam_sync_service_test.mocks.dart b/packages/core/test/data/services/offline_exam_sync_service_test.mocks.dart index adeff0bb9..9eab1a78d 100644 --- a/packages/core/test/data/services/offline_exam_sync_service_test.mocks.dart +++ b/packages/core/test/data/services/offline_exam_sync_service_test.mocks.dart @@ -138,6 +138,18 @@ class _FakeBookmarkDto_20 extends _i1.SmartFake implements _i2.BookmarkDto { : super(parent, parentInvocation); } +class _FakeQotdSubmitResponseDto_21 extends _i1.SmartFake + implements _i2.QotdSubmitResponseDto { + _FakeQotdSubmitResponseDto_21(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeQotdSummaryDto_22 extends _i1.SmartFake + implements _i2.QotdSummaryDto { + _FakeQotdSummaryDto_22(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [DataSource]. /// /// See the documentation for Mockito's code generation for more information. @@ -1868,6 +1880,62 @@ class MockMockitoDataSource extends _i1.Mock implements _i2.DataSource { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + + @override + _i3.Future> getQotdQuestions() => + (super.noSuchMethod( + Invocation.method(#getQotdQuestions, []), + returnValue: _i3.Future>.value(<_i2.QotdDto>[]), + returnValueForMissingStub: _i3.Future>.value( + <_i2.QotdDto>[], + ), + ) + as _i3.Future>); + + @override + _i3.Future<_i2.QotdSubmitResponseDto> submitQotdAttempt( + int? questionId, + List? optionIds, + ) => + (super.noSuchMethod( + Invocation.method(#submitQotdAttempt, [questionId, optionIds]), + returnValue: _i3.Future<_i2.QotdSubmitResponseDto>.value( + _FakeQotdSubmitResponseDto_21( + this, + Invocation.method(#submitQotdAttempt, [questionId, optionIds]), + ), + ), + returnValueForMissingStub: + _i3.Future<_i2.QotdSubmitResponseDto>.value( + _FakeQotdSubmitResponseDto_21( + this, + Invocation.method(#submitQotdAttempt, [ + questionId, + optionIds, + ]), + ), + ), + ) + as _i3.Future<_i2.QotdSubmitResponseDto>); + + @override + _i3.Future<_i2.QotdSummaryDto> getQotdSummary() => + (super.noSuchMethod( + Invocation.method(#getQotdSummary, []), + returnValue: _i3.Future<_i2.QotdSummaryDto>.value( + _FakeQotdSummaryDto_22( + this, + Invocation.method(#getQotdSummary, []), + ), + ), + returnValueForMissingStub: _i3.Future<_i2.QotdSummaryDto>.value( + _FakeQotdSummaryDto_22( + this, + Invocation.method(#getQotdSummary, []), + ), + ), + ) + as _i3.Future<_i2.QotdSummaryDto>); } /// A class which mocks [SentryService]. diff --git a/packages/exams/lib/screens/review_analytics/widgets/hero_donut_card.dart b/packages/exams/lib/screens/review_analytics/widgets/hero_donut_card.dart index 438139c52..d8d8543ee 100644 --- a/packages/exams/lib/screens/review_analytics/widgets/hero_donut_card.dart +++ b/packages/exams/lib/screens/review_analytics/widgets/hero_donut_card.dart @@ -1,7 +1,6 @@ import 'package:core/core.dart'; import 'package:flutter/widgets.dart'; import '../../../models/analytics_overview.dart'; -import 'donut_chart.dart'; import 'donut_legend.dart'; class HeroDonutCard extends StatelessWidget { diff --git a/packages/exams/lib/screens/review_analytics/widgets/section_donut_list.dart b/packages/exams/lib/screens/review_analytics/widgets/section_donut_list.dart index 276185e24..8c95dc629 100644 --- a/packages/exams/lib/screens/review_analytics/widgets/section_donut_list.dart +++ b/packages/exams/lib/screens/review_analytics/widgets/section_donut_list.dart @@ -1,7 +1,6 @@ import 'package:core/core.dart'; import 'package:flutter/widgets.dart'; import '../../../models/section_performance_overview.dart'; -import 'donut_chart.dart'; class SectionDonutList extends StatelessWidget { const SectionDonutList({super.key, required this.sections}); diff --git a/packages/exams/lib/screens/subject_analytics/widgets/donut_chart.dart b/packages/exams/lib/screens/subject_analytics/widgets/donut_chart.dart index bb27bcdd4..bad2e5996 100644 --- a/packages/exams/lib/screens/subject_analytics/widgets/donut_chart.dart +++ b/packages/exams/lib/screens/subject_analytics/widgets/donut_chart.dart @@ -2,8 +2,10 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'package:core/core.dart'; -class DonutChart extends StatelessWidget { - const DonutChart({ +// Local donut chart for subject-analytics. Uses percentage-based slices with +// explicit colors — distinct API from core's count-based DonutChart. +class SubjectDonutChart extends StatelessWidget { + const SubjectDonutChart({ super.key, required this.correctPct, required this.incorrectPct, @@ -35,7 +37,7 @@ class DonutChart extends StatelessWidget { width: resolvedSize, height: resolvedSize, child: CustomPaint( - painter: _DonutPainter( + painter: _SubjectDonutPainter( correctPct: correctPct, incorrectPct: incorrectPct, unansweredPct: unansweredPct, @@ -50,8 +52,8 @@ class DonutChart extends StatelessWidget { } } -class _DonutPainter extends CustomPainter { - const _DonutPainter({ +class _SubjectDonutPainter extends CustomPainter { + const _SubjectDonutPainter({ required this.correctPct, required this.incorrectPct, required this.unansweredPct, @@ -110,7 +112,7 @@ class _DonutPainter extends CustomPainter { } @override - bool shouldRepaint(covariant _DonutPainter oldDelegate) { + bool shouldRepaint(covariant _SubjectDonutPainter oldDelegate) { return oldDelegate.correctPct != correctPct || oldDelegate.incorrectPct != incorrectPct || oldDelegate.unansweredPct != unansweredPct || diff --git a/packages/exams/lib/screens/subject_analytics/widgets/individual_reports_view.dart b/packages/exams/lib/screens/subject_analytics/widgets/individual_reports_view.dart index d51603388..9929a79e1 100644 --- a/packages/exams/lib/screens/subject_analytics/widgets/individual_reports_view.dart +++ b/packages/exams/lib/screens/subject_analytics/widgets/individual_reports_view.dart @@ -648,7 +648,7 @@ class _DonutCard extends StatelessWidget { Row( children: [ // Donut Chart - DonutChart( + SubjectDonutChart( correctPct: correctPct, incorrectPct: incorrectPct, unansweredPct: unansweredPct, diff --git a/packages/testpress/lib/navigation/routes/global_routes.dart b/packages/testpress/lib/navigation/routes/global_routes.dart index d5b5750fd..9e946be3c 100644 --- a/packages/testpress/lib/navigation/routes/global_routes.dart +++ b/packages/testpress/lib/navigation/routes/global_routes.dart @@ -6,6 +6,7 @@ import 'package:courses/courses.dart'; import '../../screens/bookmarks/bookmarks_screen.dart'; import '../../screens/my_report_screen.dart'; +import '../../screens/dashboard/qotd_screen.dart'; import '../../screens/live_streams/live_stream_list_screen.dart'; class GlobalRoutes { @@ -133,6 +134,11 @@ class GlobalRoutes { parentNavigatorKey: rootNavigatorKey, builder: (context, state) => const MyReportScreen(), ), + GoRoute( + path: '/qotd', + parentNavigatorKey: rootNavigatorKey, + builder: (context, state) => const QotdScreen(), + ), GoRoute( path: '/typography-gallery', parentNavigatorKey: rootNavigatorKey, diff --git a/packages/testpress/lib/screens/dashboard/qotd/qotd_overview_screen.dart b/packages/testpress/lib/screens/dashboard/qotd/qotd_overview_screen.dart new file mode 100644 index 000000000..31a8c3d75 --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/qotd_overview_screen.dart @@ -0,0 +1,387 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:core/core.dart'; +import 'package:exams/exams.dart'; +import 'widgets/qotd_completion_gauge.dart'; +import 'widgets/qotd_chart_legend_row.dart'; + +/// The QOTD landing / statistics overview screen. +class QotdOverviewScreen extends StatelessWidget { + final List questions; + final ValueChanged onStartQuiz; + + const QotdOverviewScreen({ + super.key, + required this.questions, + required this.onStartQuiz, + }); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + final total = questions.length; + final attempted = questions.where((q) => q.pastAttempt != null).length; + final correct = questions + .where((q) => q.pastAttempt != null && q.pastAttempt!.isCorrect) + .length; + final incorrect = attempted - correct; + final unanswered = total - attempted; + final isCompleted = total > 0 && attempted == total; + final percentage = total > 0 ? ((attempted / total) * 100).toInt() : 0; + + final subjects = questions + .map((q) => q.subject) + .where((s) => s != null && s.trim().isNotEmpty) + .map((s) => s!.trim()) + .toSet() + .toList(); + final subjectsText = subjects.isNotEmpty ? subjects.join(', ') : '—'; + + final difficulties = questions + .map((q) => q.difficulty) + .where((d) => d != null && d.trim().isNotEmpty) + .toSet() + .toList(); + final difficultyText = difficulties.length == 1 + ? difficulties.first! + : l10n.qotdMixed; + + final formattedDate = DateFormat('EEEE, MMMM d, y').format(DateTime.now()); + + int firstUnattemptedIndex = questions.indexWhere( + (q) => q.pastAttempt == null, + ); + if (firstUnattemptedIndex == -1) firstUnattemptedIndex = 0; + + final ctaLabel = isCompleted + ? l10n.qotdViewSolutions + : (attempted > 0 ? l10n.qotdResumeQuiz : l10n.qotdStartQuiz); + + final String statusLabel; + final Color statusColor; + if (isCompleted) { + statusLabel = l10n.qotdStatusCompleted; + statusColor = design.colors.success; + } else if (attempted > 0) { + statusLabel = l10n.qotdStatusInProgress; + statusColor = design.colors.accent2; + } else { + statusLabel = l10n.qotdStatusNotStarted; + statusColor = design.colors.textSecondary; + } + + final String subtitleText; + if (isCompleted) { + subtitleText = l10n.qotdSubtitleCompleted; + } else if (attempted > 0) { + subtitleText = l10n.qotdSubtitleInProgress(unanswered); + } else { + subtitleText = l10n.qotdSubtitleNotStarted; + } + + return Column( + children: [ + AppHeader( + title: l10n.qotdTitle, + leading: AppBackButton(onTap: () => context.pop()), + ), + Expanded( + child: AppScroll( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + children: [ + // Date row + Padding( + padding: EdgeInsets.only( + left: design.spacing.xs, + bottom: design.spacing.md, + ), + child: Row( + children: [ + Icon( + LucideIcons.calendar, + size: 18, + color: design.colors.primary, + ), + SizedBox(width: design.spacing.sm), + AppText.label( + formattedDate, + color: design.colors.textSecondary, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + ], + ), + ), + + // Progress card + Container( + width: double.infinity, + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + QotdCompletionGauge(percentage: percentage), + SizedBox(width: design.spacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.sm, + vertical: design.spacing.xs * 0.6, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.12), + borderRadius: design.radius.pill, + border: Border.all( + color: statusColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: AppText.labelSmall( + statusLabel, + color: statusColor, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 10, + letterSpacing: 0.5, + ), + ), + ), + SizedBox(height: design.spacing.xs), + AppText.headline( + l10n.qotdAttemptedCount(attempted, total), + color: design.colors.textPrimary, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + SizedBox(height: design.spacing.xs), + AppText.bodySmall( + subtitleText, + color: design.colors.textSecondary, + style: const TextStyle(height: 1.35, fontSize: 12), + ), + SizedBox(height: design.spacing.md), + AppSemantics.progressValue( + value: total > 0 + ? (attempted / total).clamp(0.0, 1.0) + : 0.0, + label: l10n.qotdAttemptedCount(attempted, total), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Container( + height: 5, + width: double.infinity, + color: design.colors.success.withValues( + alpha: 0.12, + ), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: total > 0 + ? (attempted / total).clamp(0.0, 1.0) + : 0, + child: Container( + decoration: BoxDecoration( + color: design.colors.success, + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + + SizedBox(height: design.spacing.lg), + + // Donut breakdown (always visible matching web design) + Container( + width: double.infinity, + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Row( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xs, + ), + child: DonutChart( + correct: correct, + incorrect: incorrect, + unanswered: unanswered, + size: 96, + strokeWidth: 14, + ), + ), + SizedBox(width: design.spacing.xl), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + QotdChartLegendRow( + color: design.colors.accent4, + label: l10n.qotdCorrect, + count: correct, + ), + SizedBox(height: design.spacing.sm), + QotdChartLegendRow( + color: design.colors.accent5, + label: l10n.qotdIncorrect, + count: incorrect, + ), + SizedBox(height: design.spacing.sm), + QotdChartLegendRow( + color: design.colors.accent3, + label: l10n.qotdUnanswered, + count: unanswered, + ), + ], + ), + ), + ], + ), + ), + SizedBox(height: design.spacing.lg), + + // Metadata cards + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _MetadataCard( + icon: LucideIcons.chartColumn, + iconColor: design.colors.primary, + label: l10n.qotdDifficulty, + value: difficultyText, + maxLines: 1, + ), + ), + SizedBox(width: design.spacing.md), + Expanded( + child: _MetadataCard( + icon: LucideIcons.bookOpen, + iconColor: design.colors.success, + label: l10n.qotdTargetedSubjects, + value: subjectsText, + maxLines: 2, + ), + ), + ], + ), + ), + SizedBox(height: design.spacing.xl), + + // CTA button + AppButton.primary( + label: ctaLabel, + fullWidth: true, + onPressed: () => onStartQuiz(firstUnattemptedIndex), + ), + ], + ), + ), + ], + ); + } +} + +class _MetadataCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String label; + final String value; + final int maxLines; + + const _MetadataCard({ + required this.icon, + required this.iconColor, + required this.label, + required this.value, + required this.maxLines, + }); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + return Container( + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: iconColor), + ), + SizedBox(height: design.spacing.md), + AppText.labelSmall( + label, + color: design.colors.textSecondary, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 11, + letterSpacing: 0.6, + ), + ), + SizedBox(height: design.spacing.xs), + AppText.title( + value, + color: design.colors.textPrimary, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: maxLines > 1 ? 15 : 16, + height: 1.3, + ), + ), + ], + ), + ); + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.dart b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.dart new file mode 100644 index 000000000..18a842dea --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.dart @@ -0,0 +1,140 @@ +import 'package:flutter/widgets.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:core/core.dart'; +import 'package:core/data/data.dart'; + +part 'qotd_quiz_controller.g.dart'; + +/// Holds all mutable quiz state for a QOTD session. +class QotdQuizState { + final int currentIndex; + final Map> selectedOptionIds; + final Map isSubmittedMap; + final Map submitResponses; + final bool isSubmitting; + final bool hasSubmittedNewAnswer; + + const QotdQuizState({ + this.currentIndex = 0, + this.selectedOptionIds = const {}, + this.isSubmittedMap = const {}, + this.submitResponses = const {}, + this.isSubmitting = false, + this.hasSubmittedNewAnswer = false, + }); + + QotdQuizState copyWith({ + int? currentIndex, + Map>? selectedOptionIds, + Map? isSubmittedMap, + Map? submitResponses, + bool? isSubmitting, + bool? hasSubmittedNewAnswer, + }) { + return QotdQuizState( + currentIndex: currentIndex ?? this.currentIndex, + selectedOptionIds: selectedOptionIds ?? this.selectedOptionIds, + isSubmittedMap: isSubmittedMap ?? this.isSubmittedMap, + submitResponses: submitResponses ?? this.submitResponses, + isSubmitting: isSubmitting ?? this.isSubmitting, + hasSubmittedNewAnswer: + hasSubmittedNewAnswer ?? this.hasSubmittedNewAnswer, + ); + } +} + +@riverpod +class QotdQuizController extends _$QotdQuizController { + @override + QotdQuizState build({ + required List questions, + required int initialIndex, + }) { + final selectedOptions = >{}; + final submittedMap = {}; + final responses = {}; + + for (int i = 0; i < questions.length; i++) { + final q = questions[i]; + if (q.pastAttempt != null) { + submittedMap[i] = true; + responses[i] = q.pastAttempt!; + if (q.pastAttempt!.selectedAnswerIds.isNotEmpty) { + selectedOptions[i] = q.pastAttempt!.selectedAnswerIds.toSet(); + } + } + } + + return QotdQuizState( + currentIndex: initialIndex, + selectedOptionIds: selectedOptions, + isSubmittedMap: submittedMap, + submitResponses: responses, + ); + } + + void selectOption( + int questionIndex, + int optionId, { + bool isMultiple = false, + }) { + if (state.isSubmittedMap[questionIndex] == true || state.isSubmitting) { + return; + } + + final currentSelected = + state.selectedOptionIds[questionIndex] ?? const {}; + final Set updated; + + if (isMultiple) { + if (currentSelected.contains(optionId)) { + updated = Set.from(currentSelected)..remove(optionId); + } else { + updated = {...currentSelected, optionId}; + } + } else { + updated = {optionId}; + } + + state = state.copyWith( + selectedOptionIds: {...state.selectedOptionIds, questionIndex: updated}, + ); + } + + void setCurrentIndex(int index) { + state = state.copyWith(currentIndex: index); + } + + Future submitCurrentAnswer(BuildContext context) async { + final index = state.currentIndex; + final currentQ = questions[index]; + final selectedOptionIds = state.selectedOptionIds[index]; + if (selectedOptionIds == null || selectedOptionIds.isEmpty) return; + + state = state.copyWith(isSubmitting: true); + + try { + final repository = ref.read(qotdRepositoryProvider); + final result = await repository.submitAttempt( + questionId: currentQ.id, + optionIds: selectedOptionIds.toList(), + ); + + state = state.copyWith( + submitResponses: {...state.submitResponses, index: result}, + isSubmittedMap: {...state.isSubmittedMap, index: true}, + isSubmitting: false, + hasSubmittedNewAnswer: true, + ); + } catch (e) { + state = state.copyWith(isSubmitting: false); + if (context.mounted) { + AppToast.show( + context, + message: L10n.of(context).qotdErrorSubmitAnswer, + isError: true, + ); + } + } + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.g.dart b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.g.dart new file mode 100644 index 000000000..2d3c9f337 --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_controller.g.dart @@ -0,0 +1,197 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'qotd_quiz_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$qotdQuizControllerHash() => + r'05ad8c00eba90bb0663ad9b119e772673ba526f8'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$QotdQuizController + extends BuildlessAutoDisposeNotifier { + late final List questions; + late final int initialIndex; + + QotdQuizState build({ + required List questions, + required int initialIndex, + }); +} + +/// See also [QotdQuizController]. +@ProviderFor(QotdQuizController) +const qotdQuizControllerProvider = QotdQuizControllerFamily(); + +/// See also [QotdQuizController]. +class QotdQuizControllerFamily extends Family { + /// See also [QotdQuizController]. + const QotdQuizControllerFamily(); + + /// See also [QotdQuizController]. + QotdQuizControllerProvider call({ + required List questions, + required int initialIndex, + }) { + return QotdQuizControllerProvider( + questions: questions, + initialIndex: initialIndex, + ); + } + + @override + QotdQuizControllerProvider getProviderOverride( + covariant QotdQuizControllerProvider provider, + ) { + return call( + questions: provider.questions, + initialIndex: provider.initialIndex, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'qotdQuizControllerProvider'; +} + +/// See also [QotdQuizController]. +class QotdQuizControllerProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [QotdQuizController]. + QotdQuizControllerProvider({ + required List questions, + required int initialIndex, + }) : this._internal( + () => QotdQuizController() + ..questions = questions + ..initialIndex = initialIndex, + from: qotdQuizControllerProvider, + name: r'qotdQuizControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$qotdQuizControllerHash, + dependencies: QotdQuizControllerFamily._dependencies, + allTransitiveDependencies: + QotdQuizControllerFamily._allTransitiveDependencies, + questions: questions, + initialIndex: initialIndex, + ); + + QotdQuizControllerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.questions, + required this.initialIndex, + }) : super.internal(); + + final List questions; + final int initialIndex; + + @override + QotdQuizState runNotifierBuild(covariant QotdQuizController notifier) { + return notifier.build(questions: questions, initialIndex: initialIndex); + } + + @override + Override overrideWith(QotdQuizController Function() create) { + return ProviderOverride( + origin: this, + override: QotdQuizControllerProvider._internal( + () => create() + ..questions = questions + ..initialIndex = initialIndex, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + questions: questions, + initialIndex: initialIndex, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement + createElement() { + return _QotdQuizControllerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is QotdQuizControllerProvider && + other.questions == questions && + other.initialIndex == initialIndex; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, questions.hashCode); + hash = _SystemHash.combine(hash, initialIndex.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin QotdQuizControllerRef on AutoDisposeNotifierProviderRef { + /// The parameter `questions` of this provider. + List get questions; + + /// The parameter `initialIndex` of this provider. + int get initialIndex; +} + +class _QotdQuizControllerProviderElement + extends + AutoDisposeNotifierProviderElement + with QotdQuizControllerRef { + _QotdQuizControllerProviderElement(super.provider); + + @override + List get questions => + (origin as QotdQuizControllerProvider).questions; + @override + int get initialIndex => (origin as QotdQuizControllerProvider).initialIndex; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_screen.dart b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_screen.dart new file mode 100644 index 000000000..7d76a21b3 --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/qotd_quiz_screen.dart @@ -0,0 +1,668 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:core/core.dart'; +import 'package:core/data/data.dart'; +import 'qotd_quiz_controller.dart'; + +/// The interactive QOTD quiz screen with new UI design. Reads state from [QotdQuizController]. +class QotdQuizScreen extends ConsumerStatefulWidget { + final List questions; + final int initialIndex; + final ValueChanged onCloseQuiz; + + const QotdQuizScreen({ + super.key, + required this.questions, + required this.initialIndex, + required this.onCloseQuiz, + }); + + @override + ConsumerState createState() => _QotdQuizScreenState(); +} + +class _QotdQuizScreenState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _goToPrevious(int currentIndex, QotdQuizController controller) { + if (currentIndex > 0) { + controller.setCurrentIndex(currentIndex - 1); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + } + + void _goToNext(int currentIndex, QotdQuizController controller) { + if (currentIndex < widget.questions.length - 1) { + controller.setCurrentIndex(currentIndex + 1); + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + } + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + final state = ref.watch( + qotdQuizControllerProvider( + questions: widget.questions, + initialIndex: widget.initialIndex, + ), + ); + final controller = ref.read( + qotdQuizControllerProvider( + questions: widget.questions, + initialIndex: widget.initialIndex, + ).notifier, + ); + + final totalQuestions = widget.questions.length; + final currentIndex = state.currentIndex; + final isSubmitted = state.isSubmittedMap[currentIndex] ?? false; + final selectedOptions = + state.selectedOptionIds[currentIndex] ?? const {}; + final submitResponse = state.submitResponses[currentIndex]; + final question = widget.questions[currentIndex]; + final isMultipleChoice = + question.questionType == QotdQuestionType.multipleCorrect; + final questionTypeLabel = isMultipleChoice + ? l10n.qotdMultipleCorrect + : l10n.qotdSingleCorrect; + final hasSelection = selectedOptions.isNotEmpty; + final progress = totalQuestions > 0 + ? (currentIndex + 1) / totalQuestions + : 0.0; + + return ColoredBox( + color: design.colors.canvas, + child: Column( + children: [ + // Header with question counter subtitle + AppHeader( + title: l10n.qotdTitle, + subtitle: l10n.qotdQuestionProgress( + currentIndex + 1, + totalQuestions, + ), + leading: Transform.translate( + offset: const Offset(0, -12), + child: AppBackButton( + onTap: () => widget.onCloseQuiz(state.hasSubmittedNewAnswer), + ), + ), + showDivider: false, + ), + + // Linear Question Progress Bar + AppSemantics.progressValue( + value: progress, + label: l10n.qotdQuestionProgress(currentIndex + 1, totalQuestions), + child: LayoutBuilder( + builder: (context, constraints) { + return Container( + height: 3, + width: double.infinity, + color: design.colors.divider, + alignment: Alignment.centerLeft, + child: AnimatedContainer( + duration: MotionPreferences.duration( + context, + design.motion.normal, + ), + curve: MotionPreferences.curve( + context, + design.motion.easeOut, + ), + width: constraints.maxWidth * progress, + color: design.colors.primary, + ), + ); + }, + ), + ), + + // Scrollable Question Content + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Question Metadata Pill (e.g. "General • Hard • Single Correct") + Align( + alignment: Alignment.centerLeft, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.xs * 1.5, + ), + decoration: BoxDecoration( + color: design.colors.surfaceVariant, + borderRadius: design.radius.pill, + ), + child: Text( + [ + question.subject ?? l10n.qotdGeneral, + question.difficulty, + questionTypeLabel, + ].nonNulls + .where((text) => text.trim().isNotEmpty) + .map((text) => text.trim()) + .join(' • '), + style: design.typography.labelSmall.copyWith( + color: design.colors.textPrimary, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + ), + ), + SizedBox(height: design.spacing.md), + + // Question Title + AppHtmlV2( + data: question.htmlContent, + fontSize: 16.0, + fontWeight: FontWeight.w600, + textColor: design.colors.textPrimary, + baseUrl: Uri.tryParse(AppConfig.apiBaseUrl), + ), + SizedBox(height: design.spacing.lg), + + // Option Cards + ...question.options.indexed.map((entry) { + final index = entry.$1; + final option = entry.$2; + final isSelected = selectedOptions.contains(option.id); + final isCorrectOption = + submitResponse?.correctAnswerIds.contains(option.id) ?? + false; + final isUserSelectedWrong = + isSubmitted && isSelected && !isCorrectOption; + final isUserSelectedCorrect = + isSubmitted && isSelected && isCorrectOption; + final isMissedCorrect = + isSubmitted && !isSelected && isCorrectOption; + + return Padding( + padding: EdgeInsets.only(bottom: design.spacing.sm), + child: _buildOptionCard( + design: design, + l10n: l10n, + optionIndex: index, + option: option, + isSelected: isSelected, + isMultipleChoice: isMultipleChoice, + isSubmitted: isSubmitted, + isUserSelectedWrong: isUserSelectedWrong, + isUserSelectedCorrect: isUserSelectedCorrect, + isMissedCorrect: isMissedCorrect, + onTap: (isSubmitted || state.isSubmitting) + ? null + : () => controller.selectOption( + currentIndex, + option.id, + isMultiple: isMultipleChoice, + ), + ), + ); + }), + + // Explanation Banner + if (isSubmitted && submitResponse != null) ...[ + SizedBox(height: design.spacing.md), + _buildExplanationCard( + design: design, + l10n: l10n, + isCorrect: submitResponse.isCorrect, + explanation: submitResponse.explanation.isNotEmpty + ? submitResponse.explanation + : l10n.qotdNoExplanation, + ), + ], + ], + ), + ), + ), + + // Bottom Action Button(s) + SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + design.spacing.md, + design.spacing.xs, + design.spacing.md, + design.spacing.md, + ), + child: _buildActionButtons( + design: design, + l10n: l10n, + currentIndex: currentIndex, + totalQuestions: totalQuestions, + isSubmitted: isSubmitted, + hasSelection: hasSelection, + isSubmitting: state.isSubmitting, + onPrevious: () => _goToPrevious(currentIndex, controller), + onSubmit: () => controller.submitCurrentAnswer(context), + onNext: () { + if (currentIndex == totalQuestions - 1) { + widget.onCloseQuiz(state.hasSubmittedNewAnswer); + } else { + _goToNext(currentIndex, controller); + } + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildOptionCard({ + required DesignConfig design, + required AppLocalizations l10n, + required int optionIndex, + required QotdOptionDto option, + required bool isSelected, + required bool isMultipleChoice, + required bool isSubmitted, + required bool isUserSelectedWrong, + required bool isUserSelectedCorrect, + required bool isMissedCorrect, + required VoidCallback? onTap, + }) { + // Determine card background and border colors + final Color backgroundColor; + final Color borderColor; + final double borderWidth; + + if (isUserSelectedWrong) { + backgroundColor = design.colors.error.withValues(alpha: 0.15); + borderColor = design.colors.error; + borderWidth = 1.5; + } else if (isUserSelectedCorrect) { + backgroundColor = design.colors.success.withValues(alpha: 0.2); + borderColor = design.colors.success; + borderWidth = 1.5; + } else if (isMissedCorrect) { + backgroundColor = design.colors.card; + borderColor = design.colors.success; + borderWidth = 1.5; + } else if (isSelected && !isSubmitted) { + backgroundColor = design.colors.primary; + borderColor = design.colors.primary; + borderWidth = 1.5; + } else { + backgroundColor = design.colors.card; + borderColor = design.colors.border; + borderWidth = 1.0; + } + + // Determine text color + final Color textColor = (isSelected && !isSubmitted) + ? design.colors.onPrimary + : design.colors.textPrimary; + + final optionLetter = String.fromCharCode(65 + optionIndex); + final semanticLabel = '${l10n.qotdOptionLabel} $optionLetter'; + + return AppSemantics.button( + label: semanticLabel, + enabled: onTap != null, + onTap: onTap, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: MotionPreferences.duration(context, design.motion.fast), + curve: MotionPreferences.curve(context, design.motion.easeOut), + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: design.spacing.md, + vertical: design.spacing.md, + ), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(design.radius.lg), + border: Border.all(color: borderColor, width: borderWidth), + ), + child: Row( + children: [ + // Leading Selector Indicator + _buildSelectorIndicator( + design: design, + isMultipleChoice: isMultipleChoice, + isSelected: isSelected, + isSubmitted: isSubmitted, + isUserSelectedWrong: isUserSelectedWrong, + isUserSelectedCorrect: isUserSelectedCorrect, + ), + SizedBox(width: design.spacing.sm), + + // Option Content Text + Expanded( + child: AppHtmlV2( + data: option.htmlContent, + fontSize: 15.0, + fontWeight: FontWeight.w500, + textColor: textColor, + baseUrl: Uri.tryParse(AppConfig.apiBaseUrl), + ), + ), + + // Trailing Feedback Icon (after submission) + if (isSubmitted) ...[ + SizedBox(width: design.spacing.xs), + if (isUserSelectedWrong) + Icon(LucideIcons.x, color: design.colors.error, size: 20) + else if (isUserSelectedCorrect || isMissedCorrect) + Icon( + LucideIcons.check, + color: design.colors.success, + size: 20, + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildSelectorIndicator({ + required DesignConfig design, + required bool isMultipleChoice, + required bool isSelected, + required bool isSubmitted, + required bool isUserSelectedWrong, + required bool isUserSelectedCorrect, + }) { + final double size = 20.0; + + if (isMultipleChoice) { + // Checkbox Indicator + final Color boxColor; + final Color borderColor; + final Widget? icon; + + if (isUserSelectedWrong) { + boxColor = design.colors.error.withValues(alpha: 0.2); + borderColor = design.colors.error; + icon = Icon(LucideIcons.check, size: 14, color: design.colors.error); + } else if (isUserSelectedCorrect) { + boxColor = design.colors.success.withValues(alpha: 0.2); + borderColor = design.colors.success; + icon = Icon( + LucideIcons.check, + size: 14, + color: design.colors.onPrimary, + ); + } else if (isSelected && !isSubmitted) { + boxColor = design.colors.onPrimary.withValues(alpha: 0.2); + borderColor = design.colors.onPrimary; + icon = Icon( + LucideIcons.check, + size: 14, + color: design.colors.onPrimary, + ); + } else { + boxColor = design.colors.transparent; + borderColor = design.colors.textTertiary; + icon = null; + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: boxColor, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: borderColor, width: 1.5), + ), + child: Center(child: icon), + ); + } else { + // Radio Indicator + final Color ringColor; + final Color? innerDotColor; + + if (isUserSelectedWrong) { + ringColor = design.colors.error; + innerDotColor = design.colors.error; + } else if (isUserSelectedCorrect) { + ringColor = design.colors.success; + innerDotColor = design.colors.success; + } else if (isSelected && !isSubmitted) { + ringColor = design.colors.onPrimary; + innerDotColor = design.colors.onPrimary; + } else { + ringColor = design.colors.textTertiary; + innerDotColor = null; + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ringColor, width: 1.5), + ), + child: innerDotColor != null + ? Center( + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: innerDotColor, + ), + ), + ) + : null, + ); + } + } + + Widget _buildExplanationCard({ + required DesignConfig design, + required AppLocalizations l10n, + required bool isCorrect, + required String explanation, + }) { + final Color bgColor = isCorrect + ? design.colors.success.withValues(alpha: 0.15) + : design.colors.error.withValues(alpha: 0.15); + final Color borderColor = isCorrect + ? design.colors.success.withValues(alpha: 0.4) + : design.colors.error.withValues(alpha: 0.4); + final IconData statusIcon = isCorrect + ? LucideIcons.checkCircle2 + : LucideIcons.xCircle; + final Color statusIconColor = isCorrect + ? design.colors.success + : design.colors.error; + final String statusTitle = isCorrect + ? l10n.qotdCorrect + : l10n.qotdIncorrect; + + return Container( + width: double.infinity, + padding: EdgeInsets.all(design.spacing.md), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(design.radius.lg), + border: Border.all(color: borderColor, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(statusIcon, color: statusIconColor, size: 20), + SizedBox(width: design.spacing.xs), + AppText.title( + statusTitle, + color: design.colors.textPrimary, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + SizedBox(height: design.spacing.sm), + Container( + height: 1, + width: double.infinity, + color: statusIconColor.withValues(alpha: 0.2), + ), + SizedBox(height: design.spacing.sm), + AppText.labelBold( + l10n.qotdExplanationTitle, + color: design.colors.textPrimary, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + SizedBox(height: design.spacing.xs), + AppHtmlV2( + data: explanation, + fontSize: 14.0, + textColor: design.colors.textPrimary, + baseUrl: Uri.tryParse(AppConfig.apiBaseUrl), + ), + ], + ), + ); + } + + Widget _buildActionButtons({ + required DesignConfig design, + required AppLocalizations l10n, + required int currentIndex, + required int totalQuestions, + required bool isSubmitted, + required bool hasSelection, + required bool isSubmitting, + required VoidCallback onPrevious, + required VoidCallback onSubmit, + required VoidCallback onNext, + }) { + final bool hasPrevious = currentIndex > 0; + final bool isLastQuestion = currentIndex == totalQuestions - 1; + final bool isEnabled = isSubmitted || hasSelection; + final Color primaryButtonColor = isEnabled + ? design.colors.primary + : design.colors.surfaceVariant; + final Color primaryTextColor = isEnabled + ? design.colors.onPrimary + : design.colors.textTertiary; + final String primaryLabel = isSubmitted + ? (isLastQuestion ? l10n.qotdFinish : l10n.qotdNextQuestion) + : l10n.qotdCheckAnswer; + + const double buttonHeight = 48.0; + + final primaryButton = AppSemantics.button( + label: primaryLabel, + enabled: isEnabled && !isSubmitting, + onTap: (isEnabled && !isSubmitting) + ? (isSubmitted ? onNext : onSubmit) + : null, + child: GestureDetector( + onTap: (isEnabled && !isSubmitting) + ? (isSubmitted ? onNext : onSubmit) + : null, + child: Container( + height: buttonHeight, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + decoration: BoxDecoration( + color: primaryButtonColor, + borderRadius: design.radius.pill, + ), + child: isSubmitting + ? SizedBox( + width: 20, + height: 20, + child: AppLoadingIndicator(color: design.colors.onPrimary), + ) + : AppText.labelBold( + primaryLabel, + color: primaryTextColor, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15.0, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ); + + if (!hasPrevious) { + return SizedBox(width: double.infinity, child: primaryButton); + } + + final previousButton = AppSemantics.button( + label: l10n.qotdPrevious, + enabled: !isSubmitting, + onTap: isSubmitting ? null : onPrevious, + child: GestureDetector( + onTap: isSubmitting ? null : onPrevious, + child: Container( + height: buttonHeight, + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: design.spacing.md), + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: design.radius.pill, + border: Border.all(color: design.colors.border, width: 1.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + LucideIcons.chevronLeft, + size: 16, + color: design.colors.textPrimary, + ), + const SizedBox(width: 4.0), + AppText.label( + l10n.qotdPrevious, + color: design.colors.textPrimary, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + ), + ], + ), + ), + ), + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + previousButton, + SizedBox(width: design.spacing.sm), + Expanded(child: primaryButton), + ], + ); + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_chart_legend_row.dart b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_chart_legend_row.dart new file mode 100644 index 000000000..ae7e9a3c3 --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_chart_legend_row.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +/// A single legend row for the QOTD donut chart breakdown. +class QotdChartLegendRow extends StatelessWidget { + final Color color; + final String label; + final int count; + + const QotdChartLegendRow({ + super.key, + required this.color, + required this.label, + required this.count, + }); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + return Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + ), + ), + SizedBox(width: design.spacing.sm), + AppText.label( + label, + color: design.colors.textSecondary, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13), + ), + const Spacer(), + AppText.labelBold( + '$count', + color: design.colors.textPrimary, + style: const TextStyle(fontSize: 14), + ), + ], + ); + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_completion_gauge.dart b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_completion_gauge.dart new file mode 100644 index 000000000..a2bfa7eb8 --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_completion_gauge.dart @@ -0,0 +1,123 @@ +import 'dart:math' as math; +import 'package:flutter/widgets.dart'; +import 'package:core/core.dart'; + +/// Circular progress gauge used on the QOTD overview card. +class QotdCompletionGauge extends StatelessWidget { + final int percentage; + + const QotdCompletionGauge({super.key, required this.percentage}); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + return AppSemantics.progressValue( + value: (percentage / 100).clamp(0.0, 1.0), + label: l10n.qotdProgressLabel, + child: SizedBox( + width: 92, + height: 92, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: const Size(92, 92), + painter: _GaugePainter( + percentage: percentage, + progressColor: design.colors.success, + trackColor: design.colors.success.withValues(alpha: 0.12), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.headline( + '$percentage%', + color: design.colors.textPrimary, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 17, + height: 1.1, + ), + ), + const SizedBox(height: 3), + AppText.labelSmall( + l10n.qotdProgressLabel, + color: design.colors.textSecondary, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 9.0, + letterSpacing: 0.3, + height: 1.1, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _GaugePainter extends CustomPainter { + final int percentage; + final Color progressColor; + final Color trackColor; + + _GaugePainter({ + required this.percentage, + required this.progressColor, + required this.trackColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - 10) / 2; + const strokeWidth = 7.0; + + final trackPaint = Paint() + ..color = trackColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + final progressPaint = Paint() + ..color = progressColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + const startAngle = 0.75 * math.pi; + const sweepAngleTotal = 1.5 * math.pi; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngleTotal, + false, + trackPaint, + ); + + final sweepAngleProgress = + sweepAngleTotal * (percentage / 100).clamp(0.0, 1.0); + if (sweepAngleProgress > 0) { + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle, + sweepAngleProgress, + false, + progressPaint, + ); + } + } + + @override + bool shouldRepaint(covariant _GaugePainter oldDelegate) { + return oldDelegate.percentage != percentage || + oldDelegate.progressColor != progressColor || + oldDelegate.trackColor != trackColor; + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_overview_skeleton.dart b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_overview_skeleton.dart new file mode 100644 index 000000000..e1c86e85c --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd/widgets/qotd_overview_skeleton.dart @@ -0,0 +1,205 @@ +import 'package:flutter/widgets.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:core/core.dart'; + +/// Skeleton loader for the QOTD overview/analytics landing screen. +/// Matches the exact pixel dimensions and widget hierarchy of [QotdOverviewScreen]. +class QotdOverviewSkeleton extends StatelessWidget { + const QotdOverviewSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + return Column( + children: [ + AppHeader( + title: l10n.qotdTitle, + leading: AppBackButton(onTap: () => context.pop()), + ), + Expanded( + child: SkeletonizerConfig( + data: SkeletonizerConfigData( + effect: ShimmerEffect( + baseColor: design.colors.skeleton, + highlightColor: design.colors.onSkeleton, + ), + ), + child: Skeletonizer( + child: AppScroll( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.md, + ), + children: [ + // Date row + Padding( + padding: EdgeInsets.only( + left: design.spacing.xs, + bottom: design.spacing.md, + ), + child: Row( + children: [ + const Bone.icon(size: 18), + SizedBox(width: design.spacing.sm), + const Bone.text(words: 3, fontSize: 13), + ], + ), + ), + + // Progress card + Container( + width: double.infinity, + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Bone.circle(size: 92), + SizedBox(width: design.spacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Bone( + width: 80, + height: 20, + borderRadius: design.radius.pill, + ), + SizedBox(height: design.spacing.xs), + const Bone.text(words: 2, fontSize: 18), + SizedBox(height: design.spacing.xs), + const Bone.multiText(lines: 2, fontSize: 12), + SizedBox(height: design.spacing.md), + ClipRRect( + borderRadius: BorderRadius.circular(3), + child: const Bone( + height: 5, + width: double.infinity, + ), + ), + ], + ), + ), + ], + ), + ), + + SizedBox(height: design.spacing.lg), + + // Donut breakdown + Container( + width: double.infinity, + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues( + alpha: 0.25, + ), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Row( + children: [ + Padding( + padding: EdgeInsets.symmetric( + horizontal: design.spacing.xs, + ), + child: const Bone.circle(size: 96), + ), + SizedBox(width: design.spacing.xl), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildLegendBone(design), + SizedBox(height: design.spacing.sm), + _buildLegendBone(design), + SizedBox(height: design.spacing.sm), + _buildLegendBone(design), + ], + ), + ), + ], + ), + ), + SizedBox(height: design.spacing.lg), + + // Metadata cards + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _buildMetadataSkeleton(design)), + SizedBox(width: design.spacing.md), + Expanded(child: _buildMetadataSkeleton(design)), + ], + ), + ), + SizedBox(height: design.spacing.xl), + + // CTA button + Bone( + height: 48, + width: double.infinity, + borderRadius: design.radius.button, + ), + ], + ), + ), + ), + ), + ], + ); + } + + static Widget _buildLegendBone(DesignConfig design) { + return Row( + children: [ + Bone(width: 10, height: 10, borderRadius: BorderRadius.circular(3)), + SizedBox(width: design.spacing.sm), + const Bone.text(words: 1, fontSize: 13), + const Spacer(), + const Bone.text(words: 1, fontSize: 14), + ], + ); + } + + static Widget _buildMetadataSkeleton(DesignConfig design) { + return Container( + padding: EdgeInsets.all(design.spacing.lg), + decoration: BoxDecoration( + color: design.colors.surfaceVariant.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: design.colors.border.withValues(alpha: 0.6), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Bone(width: 38, height: 38, borderRadius: BorderRadius.circular(10)), + SizedBox(height: design.spacing.md), + const Bone.text(words: 1, fontSize: 11), + SizedBox(height: design.spacing.xs), + const Bone.text(words: 2, fontSize: 16), + ], + ), + ); + } +} diff --git a/packages/testpress/lib/screens/dashboard/qotd_screen.dart b/packages/testpress/lib/screens/dashboard/qotd_screen.dart new file mode 100644 index 000000000..1df4e0aea --- /dev/null +++ b/packages/testpress/lib/screens/dashboard/qotd_screen.dart @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:core/core.dart'; +import 'package:core/data/data.dart'; +import 'qotd/qotd_overview_screen.dart'; +import 'qotd/qotd_quiz_screen.dart'; +import 'qotd/widgets/qotd_overview_skeleton.dart'; + +/// Entry point screen for QOTD. Routes between the overview and quiz views. +class QotdScreen extends ConsumerStatefulWidget { + const QotdScreen({super.key}); + + @override + ConsumerState createState() => _QotdScreenState(); +} + +class _QotdScreenState extends ConsumerState { + bool _isInQuizMode = false; + int _initialQuizIndex = 0; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + final qotdAsync = ref.watch(qotdProvider); + + return AppShell( + backgroundColor: _isInQuizMode + ? design.colors.canvas + : design.colors.card, + child: qotdAsync.when( + skipLoadingOnReload: false, + skipLoadingOnRefresh: false, + data: (questions) { + if (questions.isEmpty) { + return Column( + children: [ + AppSemantics.header( + label: l10n.qotdTitle, + child: AppHeader( + title: l10n.qotdTitle, + leading: AppBackButton(onTap: () => context.pop()), + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.all(design.spacing.lg), + child: Container( + width: double.infinity, + constraints: const BoxConstraints(maxWidth: 480), + padding: EdgeInsets.symmetric( + horizontal: design.spacing.lg, + vertical: design.spacing.xl, + ), + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: design.radius.card, + border: Border.all(color: design.colors.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: design.colors.primary.withValues( + alpha: 0.1, + ), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.helpCircle, + size: 32, + color: design.colors.primary, + ), + ), + SizedBox(height: design.spacing.lg), + AppText.title( + l10n.qotdEmptyStateTitle, + color: design.colors.textPrimary, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: design.spacing.xs), + AppText.body( + l10n.qotdEmptyStateBody, + color: design.colors.textSecondary, + textAlign: TextAlign.center, + ), + SizedBox(height: design.spacing.xl), + AppSemantics.button( + label: l10n.qotdBackToDashboard, + onTap: () => context.pop(), + child: AppButton.primary( + label: l10n.qotdBackToDashboard, + onPressed: () => context.pop(), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + if (_isInQuizMode) { + return QotdQuizScreen( + questions: questions, + initialIndex: _initialQuizIndex, + onCloseQuiz: (hasSubmittedNew) { + if (hasSubmittedNew) { + ref.invalidate(qotdProvider); + ref.invalidate(qotdSummaryProvider); + } + setState(() => _isInQuizMode = false); + }, + ); + } + + return QotdOverviewScreen( + questions: questions, + onStartQuiz: (index) => setState(() { + _initialQuizIndex = index; + _isInQuizMode = true; + }), + ); + }, + loading: () => const QotdOverviewSkeleton(), + error: (error, stack) => Column( + children: [ + AppSemantics.header( + label: l10n.qotdTitle, + child: AppHeader( + title: l10n.qotdTitle, + leading: AppBackButton(onTap: () => context.pop()), + ), + ), + Expanded( + child: Center( + child: Padding( + padding: EdgeInsets.all(design.spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.body( + l10n.qotdErrorFailedToLoad, + color: design.colors.textSecondary, + ), + SizedBox(height: design.spacing.md), + AppSemantics.button( + label: l10n.qotdRetry, + onTap: () { + ref.invalidate(qotdProvider); + ref.invalidate(qotdSummaryProvider); + }, + child: AppButton.secondary( + label: l10n.qotdRetry, + onPressed: () { + ref.invalidate(qotdProvider); + ref.invalidate(qotdSummaryProvider); + }, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/testpress/lib/widgets/dashboard_drawer.dart b/packages/testpress/lib/widgets/dashboard_drawer.dart index 8b0098b4e..c33f1e437 100644 --- a/packages/testpress/lib/widgets/dashboard_drawer.dart +++ b/packages/testpress/lib/widgets/dashboard_drawer.dart @@ -26,6 +26,7 @@ class DashboardDrawer extends ConsumerWidget { final helpdeskEnabled = settings?.helpdeskEnabled ?? false; final enableStudentReport = !(settings?.disableStudentReport ?? false); final analyticsEnabled = !(settings?.disableStudentAnalytics ?? false); + final qotdEnabled = settings?.qotdEnabled ?? false; final bookmarksLabel = settings?.bookmarksLabel?.trim(); final displayBookmarksLabel = @@ -121,6 +122,15 @@ class DashboardDrawer extends ConsumerWidget { context.push('/my-report'); }, ), + if (qotdEnabled) + AppDrawerItem( + icon: LucideIcons.calendar, + label: l10n.qotdTitle, + action: () { + ref.read(isHomeDrawerOpenProvider.notifier).state = false; + context.push('/qotd'); + }, + ), if (AppConfig.showExamResults) AppDrawerItem( icon: LucideIcons.chartNoAxesColumn, diff --git a/packages/testpress/test/screens/dashboard/qotd_quiz_controller_test.dart b/packages/testpress/test/screens/dashboard/qotd_quiz_controller_test.dart new file mode 100644 index 000000000..4fae99d57 --- /dev/null +++ b/packages/testpress/test/screens/dashboard/qotd_quiz_controller_test.dart @@ -0,0 +1,227 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:core/data/data.dart'; +import 'package:testpress/screens/dashboard/qotd/qotd_quiz_controller.dart'; + +class FakeQotdRepository implements QotdRepository { + QotdSubmitResponseDto? responseToReturn; + int? lastSubmittedQuestionId; + List? lastSubmittedOptionIds; + + @override + Future> getQuestions() async => []; + + @override + Future getSummary() async => const QotdSummaryDto(); + + @override + Future submitAttempt({ + required int questionId, + required List optionIds, + }) async { + lastSubmittedQuestionId = questionId; + lastSubmittedOptionIds = optionIds; + return responseToReturn ?? + const QotdSubmitResponseDto( + isCorrect: true, + explanation: 'Well done!', + selectedAnswerIds: [10], + correctAnswerIds: [10], + ); + } +} + +void main() { + const sampleQuestions = [ + QotdDto( + id: 1, + questionId: 101, + htmlContent: '

Question 1 (Single Correct)

', + type: 'S', + options: [ + QotdOptionDto(id: 10, htmlContent: 'Option A'), + QotdOptionDto(id: 11, htmlContent: 'Option B'), + ], + ), + QotdDto( + id: 2, + questionId: 102, + htmlContent: '

Question 2 (Multiple Correct)

', + type: 'MCA', + options: [ + QotdOptionDto(id: 20, htmlContent: 'Option 1'), + QotdOptionDto(id: 21, htmlContent: 'Option 2'), + QotdOptionDto(id: 22, htmlContent: 'Option 3'), + ], + ), + ]; + + late FakeQotdRepository fakeRepo; + late ProviderContainer container; + + setUp(() { + fakeRepo = FakeQotdRepository(); + container = ProviderContainer( + overrides: [qotdRepositoryProvider.overrideWithValue(fakeRepo)], + ); + }); + + tearDown(() { + container.dispose(); + }); + + group('QotdQuizController Initialization', () { + test('initializes with default unattempted state and initialIndex', () { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 1, + ); + final state = container.read(provider); + + expect(state.currentIndex, 1); + expect(state.selectedOptionIds, isEmpty); + expect(state.isSubmittedMap, isEmpty); + expect(state.submitResponses, isEmpty); + expect(state.isSubmitting, isFalse); + }); + + test('pre-populates state when questions have past attempts', () { + final attemptedQuestions = [ + const QotdDto( + id: 1, + questionId: 101, + htmlContent: 'Q1', + options: [QotdOptionDto(id: 10, htmlContent: 'Opt 10')], + pastAttempt: QotdSubmitResponseDto( + isCorrect: true, + explanation: 'Previously solved', + selectedAnswerIds: [10], + correctAnswerIds: [10], + ), + ), + ]; + + final provider = qotdQuizControllerProvider( + questions: attemptedQuestions, + initialIndex: 0, + ); + final state = container.read(provider); + + expect(state.isSubmittedMap[0], isTrue); + expect(state.selectedOptionIds[0], {10}); + expect(state.submitResponses[0]?.explanation, 'Previously solved'); + }); + }); + + group('QotdQuizController Option Selection', () { + test('single-choice mode replaces previous selection', () { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 0, + ); + final notifier = container.read(provider.notifier); + + notifier.selectOption(0, 10, isMultiple: false); + expect(container.read(provider).selectedOptionIds[0], {10}); + + notifier.selectOption(0, 11, isMultiple: false); + expect(container.read(provider).selectedOptionIds[0], {11}); + }); + + test('multiple-choice mode toggles option selection', () { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 1, + ); + final notifier = container.read(provider.notifier); + + // Select option 20 + notifier.selectOption(1, 20, isMultiple: true); + expect(container.read(provider).selectedOptionIds[1], {20}); + + // Select option 21 + notifier.selectOption(1, 21, isMultiple: true); + expect(container.read(provider).selectedOptionIds[1], {20, 21}); + + // Unselect option 20 + notifier.selectOption(1, 20, isMultiple: true); + expect(container.read(provider).selectedOptionIds[1], {21}); + }); + + test('prevents selection when question is already submitted', () { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 0, + ); + final notifier = container.read(provider.notifier); + + notifier.selectOption(0, 10, isMultiple: false); + + // Artificially mark as submitted + notifier.state = notifier.state.copyWith(isSubmittedMap: {0: true}); + + // Try selecting another option + notifier.selectOption(0, 11, isMultiple: false); + expect(container.read(provider).selectedOptionIds[0], {10}); + }); + }); + + group('QotdQuizController Navigation', () { + test('setCurrentIndex updates current question index', () { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 0, + ); + final notifier = container.read(provider.notifier); + + notifier.setCurrentIndex(1); + expect(container.read(provider).currentIndex, 1); + }); + }); + + group('QotdQuizController Submission', () { + testWidgets('submits selected options to repository and records response', ( + tester, + ) async { + final provider = qotdQuizControllerProvider( + questions: sampleQuestions, + initialIndex: 0, + ); + container.listen(provider, (_, _) {}); + final notifier = container.read(provider.notifier); + + notifier.selectOption(0, 10, isMultiple: false); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: Directionality( + textDirection: TextDirection.ltr, + child: Builder( + builder: (context) { + return const SizedBox(width: 200, height: 200); + }, + ), + ), + ), + ); + + final initialState = container.read(provider); + expect(initialState.hasSubmittedNewAnswer, isFalse); + + final element = tester.element(find.byType(SizedBox)); + await notifier.submitCurrentAnswer(element); + await tester.pumpAndSettle(); + + expect(fakeRepo.lastSubmittedQuestionId, 1); + expect(fakeRepo.lastSubmittedOptionIds, [10]); + + final finalState = container.read(provider); + expect(finalState.isSubmittedMap[0], isTrue); + expect(finalState.submitResponses[0]?.isCorrect, isTrue); + expect(finalState.isSubmitting, isFalse); + expect(finalState.hasSubmittedNewAnswer, isTrue); + }); + }); +}