Skip to content

Conversation

@dbsectrainer
Copy link
Owner

Pull Request

Description

Please include a summary of the change and which issue is fixed. Also include relevant motivation and context.

Fixes # (issue)

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Other (describe):

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

claude and others added 2 commits November 8, 2025 19:03
This update transforms the passive lesson experience into an active learning system with comprehensive practice features:

**New Features:**
- Interactive practice screen with multiple exercise types (multiple choice, fill-in-blank, translation)
- Real-time progress tracking for exercises with visual indicators
- Vocabulary section with translation toggle for better learning
- Exercise completion tracking with persistent storage
- Smart practice buttons (Start/Continue/Review) based on progress

**New Models:**
- Exercise models (MultipleChoice, FillInBlank, Matching, Translation)
- Vocabulary model with phonetics, examples, and translations
- Enhanced Progress model to track exercise completion per lesson
- Enhanced Lesson model to load and manage exercises and vocabulary

**UI Enhancements:**
- Progress indicator showing X/Y exercises completed
- Interactive practice screen with immediate feedback
- Vocabulary cards with show/hide translations
- Completion celebration screen
- Navigation between exercises with skip functionality

**Sample Content:**
- Added exercises for Day 1-3 (Greetings, Numbers, Days/Months)
- Added vocabulary with phonetics for Day 1-3
- 8 exercises per lesson covering different question types

This implements all recommended best practices for language learning apps including active recall, spaced repetition cues, and clear progress feedback.
Introduces explicit StreamSubscription fields for audio player events and ensures they are cancelled in dispose. Adds mounted checks before calling setState in stream listeners and async methods to prevent state updates after widget disposal.
Copilot AI review requested due to automatic review settings November 8, 2025 19:57
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds interactive exercise and vocabulary features to the Flutter language learning app. The implementation introduces new models for exercises and vocabulary, a new practice screen for completing exercises, and updates to the progress tracking system to monitor exercise completion across languages and days.

Key Changes:

  • Added four exercise types (multiple choice, fill-in-blank, matching, translation) with validation logic
  • Introduced vocabulary items with phonetic transcriptions and example sentences
  • Implemented progress tracking for exercise completion with persistence to SharedPreferences
  • Enhanced lesson screen with vocabulary and practice sections

Reviewed Changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
pubspec.yaml Added asset paths for exercises and vocabulary JSON files
lib/models/exercise.dart New abstract Exercise class with four concrete implementations for different exercise types
lib/models/vocabulary.dart New VocabularyItem model for storing vocabulary words with translations and examples
lib/models/progress.dart Extended Progress model to track completed exercises per language and day
lib/models/lesson.dart Added methods to load exercises and vocabulary from JSON files
lib/services/progress_service.dart Added methods to mark and query exercise completion status
lib/screens/practice_screen.dart New screen for interactive exercise practice with answer validation and feedback
lib/screens/lesson_screen.dart Updated to display vocabulary and practice sections with progress tracking
assets/exercises/*.json Sample exercise JSON files for days 1-3 in English
assets/vocabulary/*.json Sample vocabulary JSON files for days 1-3 in English

}
}

Exercise get _currentExercise => widget.lesson.exercises![_currentExerciseIndex];
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code accesses widget.lesson.exercises![_currentExerciseIndex] using the null assertion operator !. While this is checked elsewhere with widget.lesson.exercises?.length, if the exercises list is empty, accessing element 0 will cause an index out of range error.

Consider adding a check to ensure the exercises list is not only non-null but also non-empty before rendering the practice screen, or redirect users back if no exercises are available.

Copilot uses AI. Check for mistakes.

Widget _buildMatching(MatchingExercise exercise) {
final leftItems = exercise.pairs.map((p) => p.left).toList();
final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle();
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _buildMatching method calls shuffle() on rightItems at line 246, which mutates the list each time the widget rebuilds. This will cause the right items to be in a different random order on every rebuild, making it confusing for users if the widget rebuilds (e.g., after hot reload, state changes, or navigation).

Consider shuffling once when the exercise is first displayed, storing the shuffled order in state, and reusing it across rebuilds.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +579
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/language.dart';
import '../models/lesson.dart';
import '../models/exercise.dart';
import '../services/progress_service.dart';
import '../utils/app_localizations.dart';

class PracticeScreen extends StatefulWidget {
final Lesson lesson;
final Language language;

const PracticeScreen({
super.key,
required this.lesson,
required this.language,
});

@override
State<PracticeScreen> createState() => _PracticeScreenState();
}

class _PracticeScreenState extends State<PracticeScreen> {
int _currentExerciseIndex = 0;
Map<String, dynamic> _userAnswers = {};
Map<String, bool> _exerciseResults = {};
bool _showFeedback = false;
bool _allExercisesCompleted = false;

@override
void initState() {
super.initState();
// Initialize with already completed exercises
final progressService = Provider.of<ProgressService>(context, listen: false);
final completedIds = progressService.getCompletedExerciseIds(
widget.language,
widget.lesson.day,
);

for (final exerciseId in completedIds) {
_exerciseResults[exerciseId] = true;
}
}

Exercise get _currentExercise => widget.lesson.exercises![_currentExerciseIndex];

bool get _canProceed => _exerciseResults[_currentExercise.id] == true;

int get _completedCount => _exerciseResults.values.where((v) => v == true).length;

int get _totalExercises => widget.lesson.exercises?.length ?? 0;

void _submitAnswer() {
final answer = _userAnswers[_currentExercise.id];
if (answer == null) return;

final isCorrect = _currentExercise.checkAnswer(answer);

setState(() {
_exerciseResults[_currentExercise.id] = isCorrect;
_showFeedback = true;
});

if (isCorrect) {
final progressService = Provider.of<ProgressService>(context, listen: false);
progressService.markExerciseComplete(
widget.language,
widget.lesson.day,
_currentExercise.id,
);
}
}

void _nextExercise() {
if (_currentExerciseIndex < _totalExercises - 1) {
setState(() {
_currentExerciseIndex++;
_showFeedback = false;
});
} else {
setState(() {
_allExercisesCompleted = true;
});
}
}

void _previousExercise() {
if (_currentExerciseIndex > 0) {
setState(() {
_currentExerciseIndex--;
_showFeedback = false;
});
}
}

Widget _buildExerciseWidget() {
final exercise = _currentExercise;

if (exercise is MultipleChoiceExercise) {
return _buildMultipleChoice(exercise);
} else if (exercise is FillInBlankExercise) {
return _buildFillInBlank(exercise);
} else if (exercise is MatchingExercise) {
return _buildMatching(exercise);
} else if (exercise is TranslationExercise) {
return _buildTranslation(exercise);
}

return const Text('Unknown exercise type');
}

Widget _buildMultipleChoice(MultipleChoiceExercise exercise) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
exercise.question,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
...List.generate(exercise.options.length, (index) {
final isSelected = _userAnswers[exercise.id] == index;
final showResult = _showFeedback && isSelected;
final isCorrect = index == exercise.correctOptionIndex;

return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: OutlinedButton(
onPressed: _showFeedback
? null
: () {
setState(() {
_userAnswers[exercise.id] = index;
});
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: showResult
? (_exerciseResults[exercise.id] == true
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1))
: (isSelected
? Theme.of(context).colorScheme.primaryContainer
: null),
side: BorderSide(
color: showResult
? (_exerciseResults[exercise.id] == true
? Colors.green
: Colors.red)
: (isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey),
width: 2,
),
),
child: Row(
children: [
Expanded(
child: Text(
exercise.options[index],
style: TextStyle(
color: showResult
? (_exerciseResults[exercise.id] == true
? Colors.green.shade700
: Colors.red.shade700)
: null,
),
),
),
if (showResult)
Icon(
_exerciseResults[exercise.id] == true
? Icons.check_circle
: Icons.cancel,
color: _exerciseResults[exercise.id] == true
? Colors.green
: Colors.red,
),
if (_showFeedback && isCorrect && !isSelected)
const Icon(Icons.check_circle, color: Colors.green),
],
),
),
);
}),
],
);
}

Widget _buildFillInBlank(FillInBlankExercise exercise) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
exercise.question,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
TextField(
enabled: !_showFeedback,
onChanged: (value) {
setState(() {
_userAnswers[exercise.id] = value;
});
},
decoration: InputDecoration(
hintText: 'Type your answer here...',
border: const OutlineInputBorder(),
suffixIcon: _showFeedback
? Icon(
_exerciseResults[exercise.id] == true
? Icons.check_circle
: Icons.cancel,
color: _exerciseResults[exercise.id] == true
? Colors.green
: Colors.red,
)
: null,
),
),
if (_showFeedback && _exerciseResults[exercise.id] == false)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Text(
'Correct answer: ${exercise.correctAnswer}',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}

Widget _buildMatching(MatchingExercise exercise) {
final leftItems = exercise.pairs.map((p) => p.left).toList();
final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle();

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
exercise.question,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
const Text('Match the items (tap to select, then tap the matching item):'),
const SizedBox(height: 16),
// For simplicity, showing a message - full implementation would need drag-and-drop
const Text(
'Matching exercise UI - Tap items to connect them',
style: TextStyle(fontStyle: FontStyle.italic),
),
// Simplified version: display pairs
...exercise.pairs.map((pair) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(child: Text(pair.left)),
const Icon(Icons.arrow_forward),
Expanded(child: Text(pair.right)),
],
),
),
);
}),
],
);
}

Widget _buildTranslation(TranslationExercise exercise) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
exercise.question,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
exercise.targetText,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
TextField(
enabled: !_showFeedback,
onChanged: (value) {
setState(() {
_userAnswers[exercise.id] = value;
});
},
decoration: InputDecoration(
hintText: 'Type your translation...',
border: const OutlineInputBorder(),
suffixIcon: _showFeedback
? Icon(
_exerciseResults[exercise.id] == true
? Icons.check_circle
: Icons.cancel,
color: _exerciseResults[exercise.id] == true
? Colors.green
: Colors.red,
)
: null,
),
maxLines: 3,
),
if (_showFeedback && _exerciseResults[exercise.id] == false)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Correct translation:',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
exercise.correctTranslation,
style: TextStyle(color: Colors.green.shade700),
),
],
),
),
),
],
);
}

@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);

if (_allExercisesCompleted) {
return Scaffold(
appBar: AppBar(
title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.celebration,
size: 100,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Congratulations!',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'You\'ve completed all exercises for this lesson!',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'$_completedCount / $_totalExercises exercises completed',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Back to Lesson'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
),
],
),
),
),
);
}

return Scaffold(
appBar: AppBar(
title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'),
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Chip(
avatar: Text(
widget.language.flag,
style: const TextStyle(fontSize: 16),
),
label: Text(
'$_completedCount / $_totalExercises',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
],
),
body: Column(
children: [
// Progress indicator
LinearProgressIndicator(
value: _totalExercises > 0 ? _completedCount / _totalExercises : 0,
minHeight: 8,
),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Exercise counter
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Exercise ${_currentExerciseIndex + 1} of $_totalExercises',
style: Theme.of(context).textTheme.titleMedium,
),
if (_exerciseResults[_currentExercise.id] == true)
const Chip(
avatar: Icon(Icons.check, size: 16),
label: Text('Completed'),
backgroundColor: Colors.green,
labelStyle: TextStyle(color: Colors.white),
),
],
),
const SizedBox(height: 24),

// Exercise content
_buildExerciseWidget(),

const SizedBox(height: 32),

// Feedback message
if (_showFeedback)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green
: Colors.red,
width: 2,
),
),
child: Row(
children: [
Icon(
_exerciseResults[_currentExercise.id] == true
? Icons.check_circle
: Icons.cancel,
color: _exerciseResults[_currentExercise.id] == true
? Colors.green
: Colors.red,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_exerciseResults[_currentExercise.id] == true
? 'Excellent! That\'s correct!'
: 'Not quite right. Try reviewing the lesson content.',
style: TextStyle(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green.shade700
: Colors.red.shade700,
fontWeight: FontWeight.bold,
),
),
),
],
),
),

const SizedBox(height: 24),

// Action buttons
if (!_showFeedback)
ElevatedButton(
onPressed: _userAnswers.containsKey(_currentExercise.id)
? _submitAnswer
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: const Text('Submit Answer'),
),

if (_showFeedback)
ElevatedButton(
onPressed: _canProceed ? _nextExercise : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(
_currentExerciseIndex < _totalExercises - 1
? 'Next Exercise'
: 'Finish Practice',
),
),

const SizedBox(height: 16),

// Navigation buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _currentExerciseIndex > 0 ? _previousExercise : null,
icon: const Icon(Icons.arrow_back),
label: const Text('Previous'),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton.icon(
onPressed: _currentExerciseIndex < _totalExercises - 1
? _nextExercise
: null,
icon: const Icon(Icons.arrow_forward),
label: const Text('Skip'),
),
),
],
),
],
),
),
),
),
],
),
);
}
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple hardcoded user-facing strings should be localized using the AppLocalizations system. This includes:

  • "Unknown exercise type" (line 109)
  • "Type your answer here..." (line 207)
  • "Correct answer: ..." (line 232)
  • "Match the items..." (line 256)
  • "Matching exercise UI..." (line 260)
  • "Type your translation..." (line 312)
  • "Correct translation:" (line 341)
  • "Congratulations!" (line 382)
  • "You've completed all exercises..." (line 387)
  • "exercises completed" (line 393)
  • "Back to Lesson" (line 403)
  • "Completed" (line 462)
  • "Excellent! That's correct!" (line 505)
  • "Not quite right..." (line 506)
  • "Submit Answer" (line 530)
  • "Next Exercise" (line 541)
  • "Finish Practice" (line 542)
  • "Previous" (line 555)
  • "Skip" (line 565)

These strings should be moved to the localization system for proper internationalization support.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +309
enum ExerciseType {
multipleChoice,
fillInBlank,
matching,
translation,
listening,
}

abstract class Exercise {
final String id;
final ExerciseType type;
final String question;
final String? audioHint;

Exercise({
required this.id,
required this.type,
required this.question,
this.audioHint,
});

bool checkAnswer(dynamic answer);
dynamic getCorrectAnswer();
Map<String, dynamic> toJson();
}

class MultipleChoiceExercise extends Exercise {
final List<String> options;
final int correctOptionIndex;

MultipleChoiceExercise({
required String id,
required String question,
required this.options,
required this.correctOptionIndex,
String? audioHint,
}) : super(
id: id,
type: ExerciseType.multipleChoice,
question: question,
audioHint: audioHint,
);

@override
bool checkAnswer(dynamic answer) {
if (answer is! int) return false;
return answer == correctOptionIndex;
}

@override
dynamic getCorrectAnswer() => correctOptionIndex;

@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': 'multipleChoice',
'question': question,
'options': options,
'correctOptionIndex': correctOptionIndex,
'audioHint': audioHint,
};
}

factory MultipleChoiceExercise.fromJson(Map<String, dynamic> json) {
return MultipleChoiceExercise(
id: json['id'],
question: json['question'],
options: List<String>.from(json['options']),
correctOptionIndex: json['correctOptionIndex'],
audioHint: json['audioHint'],
);
}
}

class FillInBlankExercise extends Exercise {
final String correctAnswer;
final List<String>? acceptableAlternatives;
final bool caseSensitive;

FillInBlankExercise({
required String id,
required String question,
required this.correctAnswer,
this.acceptableAlternatives,
this.caseSensitive = false,
String? audioHint,
}) : super(
id: id,
type: ExerciseType.fillInBlank,
question: question,
audioHint: audioHint,
);

@override
bool checkAnswer(dynamic answer) {
if (answer is! String) return false;

final userAnswer = caseSensitive ? answer : answer.toLowerCase();
final correct = caseSensitive ? correctAnswer : correctAnswer.toLowerCase();

if (userAnswer == correct) return true;

if (acceptableAlternatives != null) {
for (final alternative in acceptableAlternatives!) {
final alt = caseSensitive ? alternative : alternative.toLowerCase();
if (userAnswer == alt) return true;
}
}

return false;
}

@override
dynamic getCorrectAnswer() => correctAnswer;

@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': 'fillInBlank',
'question': question,
'correctAnswer': correctAnswer,
'acceptableAlternatives': acceptableAlternatives,
'caseSensitive': caseSensitive,
'audioHint': audioHint,
};
}

factory FillInBlankExercise.fromJson(Map<String, dynamic> json) {
return FillInBlankExercise(
id: json['id'],
question: json['question'],
correctAnswer: json['correctAnswer'],
acceptableAlternatives: json['acceptableAlternatives'] != null
? List<String>.from(json['acceptableAlternatives'])
: null,
caseSensitive: json['caseSensitive'] ?? false,
audioHint: json['audioHint'],
);
}
}

class MatchingPair {
final String left;
final String right;

MatchingPair({
required this.left,
required this.right,
});

Map<String, dynamic> toJson() {
return {
'left': left,
'right': right,
};
}

factory MatchingPair.fromJson(Map<String, dynamic> json) {
return MatchingPair(
left: json['left'],
right: json['right'],
);
}
}

class MatchingExercise extends Exercise {
final List<MatchingPair> pairs;

MatchingExercise({
required String id,
required String question,
required this.pairs,
String? audioHint,
}) : super(
id: id,
type: ExerciseType.matching,
question: question,
audioHint: audioHint,
);

@override
bool checkAnswer(dynamic answer) {
if (answer is! Map<String, String>) return false;

for (final pair in pairs) {
if (answer[pair.left] != pair.right) {
return false;
}
}

return answer.length == pairs.length;
}

@override
dynamic getCorrectAnswer() {
return Map.fromEntries(
pairs.map((pair) => MapEntry(pair.left, pair.right)),
);
}

@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': 'matching',
'question': question,
'pairs': pairs.map((p) => p.toJson()).toList(),
'audioHint': audioHint,
};
}

factory MatchingExercise.fromJson(Map<String, dynamic> json) {
return MatchingExercise(
id: json['id'],
question: json['question'],
pairs: (json['pairs'] as List)
.map((p) => MatchingPair.fromJson(p))
.toList(),
audioHint: json['audioHint'],
);
}
}

class TranslationExercise extends Exercise {
final String targetText;
final String correctTranslation;
final List<String>? acceptableAlternatives;

TranslationExercise({
required String id,
required String question,
required this.targetText,
required this.correctTranslation,
this.acceptableAlternatives,
String? audioHint,
}) : super(
id: id,
type: ExerciseType.translation,
question: question,
audioHint: audioHint,
);

@override
bool checkAnswer(dynamic answer) {
if (answer is! String) return false;

final userAnswer = answer.trim().toLowerCase();
final correct = correctTranslation.trim().toLowerCase();

if (userAnswer == correct) return true;

if (acceptableAlternatives != null) {
for (final alternative in acceptableAlternatives!) {
if (userAnswer == alternative.trim().toLowerCase()) {
return true;
}
}
}

return false;
}

@override
dynamic getCorrectAnswer() => correctTranslation;

@override
Map<String, dynamic> toJson() {
return {
'id': id,
'type': 'translation',
'question': question,
'targetText': targetText,
'correctTranslation': correctTranslation,
'acceptableAlternatives': acceptableAlternatives,
'audioHint': audioHint,
};
}

factory TranslationExercise.fromJson(Map<String, dynamic> json) {
return TranslationExercise(
id: json['id'],
question: json['question'],
targetText: json['targetText'],
correctTranslation: json['correctTranslation'],
acceptableAlternatives: json['acceptableAlternatives'] != null
? List<String>.from(json['acceptableAlternatives'])
: null,
audioHint: json['audioHint'],
);
}
}

// Factory method to create exercises from JSON
Exercise exerciseFromJson(Map<String, dynamic> json) {
switch (json['type']) {
case 'multipleChoice':
return MultipleChoiceExercise.fromJson(json);
case 'fillInBlank':
return FillInBlankExercise.fromJson(json);
case 'matching':
return MatchingExercise.fromJson(json);
case 'translation':
return TranslationExercise.fromJson(json);
default:
throw Exception('Unknown exercise type: ${json['type']}');
}
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Exercise abstract class and its concrete implementations (MultipleChoiceExercise, FillInBlankExercise, MatchingExercise, TranslationExercise) lack documentation. Consider adding class-level documentation explaining:

  • The purpose of each exercise type
  • Expected answer formats for checkAnswer()
  • The JSON structure expected by the fromJson() factory methods
  • Usage examples

This is especially important for the abstract Exercise class to guide implementers of new exercise types.

Copilot uses AI. Check for mistakes.
Comment on lines +560 to +567
child: OutlinedButton.icon(
onPressed: _currentExerciseIndex < _totalExercises - 1
? _nextExercise
: null,
icon: const Icon(Icons.arrow_forward),
label: const Text('Skip'),
),
),
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Skip" button allows users to navigate to unanswered exercises without providing an answer. However, there's no validation to prevent users from reaching the completion screen without actually completing all exercises. The _allExercisesCompleted flag is set when _currentExerciseIndex reaches the last exercise, regardless of whether that exercise (or previous ones) have been answered correctly.

This means users can skip through all exercises and see the "Congratulations!" screen even if they haven't completed any exercises successfully.

Copilot uses AI. Check for mistakes.
Comment on lines +183 to +193
@override
bool checkAnswer(dynamic answer) {
if (answer is! Map<String, String>) return false;

for (final pair in pairs) {
if (answer[pair.left] != pair.right) {
return false;
}
}

return answer.length == pairs.length;
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkAnswer method for MatchingExercise expects the answer to be of type Map<String, String>, but the UI in practice_screen.dart does not provide any interactive functionality to collect such user input. The matching exercise displays pairs statically without allowing users to create their own mappings, making this validation logic unreachable.

Suggested change
@override
bool checkAnswer(dynamic answer) {
if (answer is! Map<String, String>) return false;
for (final pair in pairs) {
if (answer[pair.left] != pair.right) {
return false;
}
}
return answer.length == pairs.length;
/// MatchingExercise is currently non-interactive; always returns true.
@override
bool checkAnswer(dynamic answer) {
// No validation performed; UI does not collect user input for matching.
return true;

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +280
Widget _buildMatching(MatchingExercise exercise) {
final leftItems = exercise.pairs.map((p) => p.left).toList();
final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle();

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
exercise.question,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
const Text('Match the items (tap to select, then tap the matching item):'),
const SizedBox(height: 16),
// For simplicity, showing a message - full implementation would need drag-and-drop
const Text(
'Matching exercise UI - Tap items to connect them',
style: TextStyle(fontStyle: FontStyle.italic),
),
// Simplified version: display pairs
...exercise.pairs.map((pair) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(child: Text(pair.left)),
const Icon(Icons.arrow_forward),
Expanded(child: Text(pair.right)),
],
),
),
);
}),
],
);
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _buildMatching method displays exercise pairs directly without allowing user interaction to submit answers. However, the method is still checking answer validity in _submitAnswer which will never receive a proper matching answer (expects Map<String, String>). This creates a dead code path where matching exercises cannot be properly validated or marked as complete.

The UI shows a static display with a message "Matching exercise UI - Tap items to connect them", but there's no actual interactive functionality implemented.

Copilot uses AI. Check for mistakes.
"id": "day1_trans1",
"type": "translation",
"question": "Translate to English:",
"targetText": "Hello",
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The translation exercise with id "day1_trans1" asks users to "Translate to English:" with targetText "Hello" and expects "Hello" as the correct translation. This is not a valid translation exercise - it's asking to translate an English word to English.

For a proper translation exercise in an English lesson, it should provide text in the learner's native language and ask them to translate it to English. The acceptable alternatives ("Hi", "Hey", "Greetings") suggest this might have been intended differently.

Suggested change
"targetText": "Hello",
"targetText": "Hola",

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +552
// Practice Section
if (_currentLesson.hasExercises)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.quiz,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Interactive Practice',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
Consumer<ProgressService>(
builder: (context, progressService, child) {
final completedCount = progressService.getCompletedExercisesCount(
widget.language,
_currentDay,
);
final totalCount = _currentLesson.totalExercises;
final progress = totalCount > 0 ? completedCount / totalCount : 0.0;

return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Progress:',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'$completedCount / $totalCount exercises',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PracticeScreen(
lesson: _currentLesson,
language: widget.language,
),
),
);
},
icon: const Icon(Icons.play_arrow),
label: Text(
completedCount == 0
? 'Start Practice'
: completedCount == totalCount
? 'Review Exercises'
: 'Continue Practice',
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
backgroundColor: Theme.of(context).colorScheme.secondary,
foregroundColor: Colors.white,
),
),
],
);
},
),
],
),
),
),

if (_currentLesson.hasExercises) const SizedBox(height: 16),

// Vocabulary Section
if (_currentLesson.hasVocabulary)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.book,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Vocabulary',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
TextButton.icon(
onPressed: () {
setState(() {
_showTranslations = !_showTranslations;
});
},
icon: Icon(_showTranslations ? Icons.visibility_off : Icons.visibility),
label: Text(_showTranslations ? 'Hide' : 'Show'),
),
],
),
const SizedBox(height: 16),
..._currentLesson.vocabulary!.map((vocab) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
vocab.word,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (vocab.phonetic != null)
Text(
vocab.phonetic!,
style: TextStyle(
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
],
),
if (_showTranslations) ...[
const SizedBox(height: 8),
Text(
vocab.translation,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
),
],
if (vocab.example != null && _showTranslations) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
vocab.example!,
style: const TextStyle(fontStyle: FontStyle.italic),
),
if (vocab.exampleTranslation != null) ...[
const SizedBox(height: 4),
Text(
vocab.exampleTranslation!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
],
),
),
],
],
),
),
);
}),
],
),
),
),

if (_currentLesson.hasVocabulary) const SizedBox(height: 16),
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiple hardcoded user-facing strings should be localized using the AppLocalizations system. This includes:

  • "Interactive Practice" (line 354)
  • "Progress:" (line 378)
  • "exercises" (line 382)
  • "Start Practice" (line 412)
  • "Review Exercises" (line 414)
  • "Continue Practice" (line 415)
  • "Vocabulary" (line 453)
  • "Hide" (line 467)
  • "Show" (line 467)

These strings should be moved to the localization system for proper internationalization support, consistent with the existing use of loc.translate() for other strings in the file.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +47
class VocabularyItem {
final String word;
final String translation;
final String? phonetic;
final String? example;
final String? exampleTranslation;
final String? audioPath;
final String? imageUrl;
final List<String>? tags;

VocabularyItem({
required this.word,
required this.translation,
this.phonetic,
this.example,
this.exampleTranslation,
this.audioPath,
this.imageUrl,
this.tags,
});

Map<String, dynamic> toJson() {
return {
'word': word,
'translation': translation,
'phonetic': phonetic,
'example': example,
'exampleTranslation': exampleTranslation,
'audioPath': audioPath,
'imageUrl': imageUrl,
'tags': tags,
};
}

factory VocabularyItem.fromJson(Map<String, dynamic> json) {
return VocabularyItem(
word: json['word'],
translation: json['translation'],
phonetic: json['phonetic'],
example: json['example'],
exampleTranslation: json['exampleTranslation'],
audioPath: json['audioPath'],
imageUrl: json['imageUrl'],
tags: json['tags'] != null ? List<String>.from(json['tags']) : null,
);
}
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VocabularyItem class lacks documentation explaining the purpose of each field. Consider adding documentation for:

  • word: The vocabulary word in the target language
  • translation: Translation to the learner's language (currently appears to be misused as a definition)
  • phonetic: Phonetic transcription (IPA format)
  • example: Example sentence using the word
  • exampleTranslation: Translation of the example sentence
  • audioPath: Path to pronunciation audio
  • imageUrl: URL to an illustrative image
  • tags: Categorization tags for the vocabulary item

Clear documentation would help clarify the intended use of each field, particularly the translation field which is currently being misused.

Copilot uses AI. Check for mistakes.
@dbsectrainer dbsectrainer requested a review from Copilot November 8, 2025 20:58
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 10 comments.


Widget _buildMatching(MatchingExercise exercise) {
final leftItems = exercise.pairs.map((p) => p.left).toList();
final rightItems = exercise.pairs.map((p) => p.right).toList()..shuffle();
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shuffle operation on line 246 modifies the list in place and could produce different orderings each time the widget rebuilds (e.g., when navigating between exercises). This creates an inconsistent user experience. Consider shuffling once during initialization or using a seeded random generator for deterministic behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +84
} catch (e) {
// No exercises available for this lesson
exercises = [];
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent error handling: When loading exercises or vocabulary fails, the exception is caught but not logged or communicated to the user. Consider adding logging (e.g., using debugPrint or a logging framework) to help with debugging issues related to missing or malformed JSON files.

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +103
final day = int.parse(dayStr);
exercisesByDay[day] = (exerciseIds as List).cast<String>().toSet();
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The int.parse(dayStr) on line 102 can throw a FormatException if the day string is malformed in the stored JSON. Consider using int.tryParse() with a fallback or wrapping this in a try-catch to handle corrupted data gracefully.

Suggested change
final day = int.parse(dayStr);
exercisesByDay[day] = (exerciseIds as List).cast<String>().toSet();
final day = int.tryParse(dayStr);
if (day != null) {
exercisesByDay[day] = (exerciseIds as List).cast<String>().toSet();
}

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +38
word: json['word'],
translation: json['translation'],
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null safety checks in fromJson: The required fields word and translation are accessed directly from JSON without null checks. If the JSON is malformed or missing these fields, this will cause a runtime error. Consider adding validation or using the as String cast to make the requirement explicit.

Suggested change
word: json['word'],
translation: json['translation'],
word: json['word'] as String,
translation: json['translation'] as String,

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +25
abstract class Exercise {
final String id;
final ExerciseType type;
final String question;
final String? audioHint;

Exercise({
required this.id,
required this.type,
required this.question,
this.audioHint,
});

bool checkAnswer(dynamic answer);
dynamic getCorrectAnswer();
Map<String, dynamic> toJson();
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation: The Exercise abstract class and its concrete implementations lack class-level and method-level documentation. Given the complexity of the exercise system with multiple types and their specific behaviors, adding documentation would improve maintainability and help other developers understand the exercise framework.

Copilot uses AI. Check for mistakes.
}
}

Exercise get _currentExercise => widget.lesson.exercises![_currentExerciseIndex];
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null safety issue: accessing widget.lesson.exercises![_currentExerciseIndex] without checking if exercises is null or if the index is within bounds. While hasExercises is checked before rendering the practice section, the list could theoretically be empty, leading to an index out of bounds error.

Copilot uses AI. Check for mistakes.
Comment on lines +382 to +579
'Congratulations!',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'You\'ve completed all exercises for this lesson!',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'$_completedCount / $_totalExercises exercises completed',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Back to Lesson'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
),
),
],
),
),
),
);
}

return Scaffold(
appBar: AppBar(
title: Text('Practice - ${loc.translate('lesson.day')} ${widget.lesson.day}'),
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Chip(
avatar: Text(
widget.language.flag,
style: const TextStyle(fontSize: 16),
),
label: Text(
'$_completedCount / $_totalExercises',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
),
],
),
body: Column(
children: [
// Progress indicator
LinearProgressIndicator(
value: _totalExercises > 0 ? _completedCount / _totalExercises : 0,
minHeight: 8,
),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Exercise counter
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Exercise ${_currentExerciseIndex + 1} of $_totalExercises',
style: Theme.of(context).textTheme.titleMedium,
),
if (_exerciseResults[_currentExercise.id] == true)
const Chip(
avatar: Icon(Icons.check, size: 16),
label: Text('Completed'),
backgroundColor: Colors.green,
labelStyle: TextStyle(color: Colors.white),
),
],
),
const SizedBox(height: 24),

// Exercise content
_buildExerciseWidget(),

const SizedBox(height: 32),

// Feedback message
if (_showFeedback)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green
: Colors.red,
width: 2,
),
),
child: Row(
children: [
Icon(
_exerciseResults[_currentExercise.id] == true
? Icons.check_circle
: Icons.cancel,
color: _exerciseResults[_currentExercise.id] == true
? Colors.green
: Colors.red,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_exerciseResults[_currentExercise.id] == true
? 'Excellent! That\'s correct!'
: 'Not quite right. Try reviewing the lesson content.',
style: TextStyle(
color: _exerciseResults[_currentExercise.id] == true
? Colors.green.shade700
: Colors.red.shade700,
fontWeight: FontWeight.bold,
),
),
),
],
),
),

const SizedBox(height: 24),

// Action buttons
if (!_showFeedback)
ElevatedButton(
onPressed: _userAnswers.containsKey(_currentExercise.id)
? _submitAnswer
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: const Text('Submit Answer'),
),

if (_showFeedback)
ElevatedButton(
onPressed: _canProceed ? _nextExercise : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
child: Text(
_currentExerciseIndex < _totalExercises - 1
? 'Next Exercise'
: 'Finish Practice',
),
),

const SizedBox(height: 16),

// Navigation buttons
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _currentExerciseIndex > 0 ? _previousExercise : null,
icon: const Icon(Icons.arrow_back),
label: const Text('Previous'),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton.icon(
onPressed: _currentExerciseIndex < _totalExercises - 1
? _nextExercise
: null,
icon: const Icon(Icons.arrow_forward),
label: const Text('Skip'),
),
),
],
),
],
),
),
),
),
],
),
);
}
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded English strings are used throughout the practice screen UI (e.g., 'Congratulations!', 'Back to Lesson', 'Submit Answer', etc.). This breaks the multilingual design pattern of the application. These strings should use the AppLocalizations system for proper internationalization support.

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +99
} catch (e) {
// No vocabulary available for this lesson
vocabulary = [];
}
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent error handling: When loading vocabulary fails, the exception is caught but not logged or communicated to the user. Consider adding logging (e.g., using debugPrint or a logging framework) to help with debugging issues related to missing or malformed JSON files.

Copilot uses AI. Check for mistakes.
fillInBlank,
matching,
translation,
listening,
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The listening exercise type is defined in the enum but has no corresponding class implementation or factory case in exerciseFromJson. This will cause an exception if a JSON file contains a listening exercise. Either implement the ListeningExercise class or remove this unused enum value.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +71
return MultipleChoiceExercise(
id: json['id'],
question: json['question'],
options: List<String>.from(json['options']),
correctOptionIndex: json['correctOptionIndex'],
audioHint: json['audioHint'],
Copy link

Copilot AI Nov 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing null safety and type checks in fromJson: Required fields like id, question, options, and correctOptionIndex are accessed without validation. If the JSON is malformed, this could cause runtime errors. Consider adding explicit type casts (e.g., json['id'] as String) and null checks to make requirements explicit and provide better error messages.

Suggested change
return MultipleChoiceExercise(
id: json['id'],
question: json['question'],
options: List<String>.from(json['options']),
correctOptionIndex: json['correctOptionIndex'],
audioHint: json['audioHint'],
// Null safety and type checks for required fields
final id = json['id'];
final question = json['question'];
final options = json['options'];
final correctOptionIndex = json['correctOptionIndex'];
if (id == null || id is! String) {
throw ArgumentError('MultipleChoiceExercise.fromJson: "id" is required and must be a String');
}
if (question == null || question is! String) {
throw ArgumentError('MultipleChoiceExercise.fromJson: "question" is required and must be a String');
}
if (options == null || options is! List) {
throw ArgumentError('MultipleChoiceExercise.fromJson: "options" is required and must be a List<String>');
}
// Ensure all options are strings
final optionsList = options.map((e) {
if (e is! String) {
throw ArgumentError('MultipleChoiceExercise.fromJson: All options must be String');
}
return e as String;
}).toList();
if (correctOptionIndex == null || correctOptionIndex is! int) {
throw ArgumentError('MultipleChoiceExercise.fromJson: "correctOptionIndex" is required and must be an int');
}
return MultipleChoiceExercise(
id: id as String,
question: question as String,
options: optionsList,
correctOptionIndex: correctOptionIndex as int,
audioHint: json['audioHint'] as String?,

Copilot uses AI. Check for mistakes.
dbsectrainer added a commit that referenced this pull request Nov 8, 2025
…9MCFi264' into claude/flutter-ui-overhaul-011CUvd9SCPCzVjz6oWsTTuJ

This merge combines the UI overhaul features with interactive practice functionality:

UI Overhaul Features (PR #13):
- Bottom navigation with 4 tabs (Home, Achievements, Profile, Settings)
- Onboarding flow with animated welcome screens
- Dark mode and accessibility features
- Gamification system with 17 achievements
- Streak tracking and daily goals
- Enhanced audio player (speed control 0.5x-2.0x, loop mode)
- Profile screen with stats and charts
- Settings screen with customization options

Interactive Practice Features (PR #14):
- Interactive practice screen with multiple exercise types
- Vocabulary section with translation toggle
- Exercise completion tracking
- Real-time progress indicators
- Sample content for Days 1-3 (exercises and vocabulary)

Conflict Resolutions:
- lesson_screen.dart: Combined audio player enhancements (loop/speed) with practice/vocabulary sections
- progress_service.dart: Merged exercise tracking methods with resetAll() functionality
- Progress model now tracks both lesson and exercise completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants