From d7862241f6acdc29f4dc50b724c19dd4341050fe Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Fri, 13 Feb 2026 03:56:03 +0700 Subject: [PATCH 1/3] feat: support audio in quizzes --- .../lib/models/learn/challenge_model.dart | 97 +++++++- .../lib/service/audio/audio_service.dart | 10 +- .../templates/quiz/quiz_viewmodel.dart | 2 + .../widgets/audio/audio_player_view.dart | 6 +- .../widgets/audio/audio_player_viewmodel.dart | 2 +- .../learn/widgets/quiz_audio_player.dart | 231 ++++++++++++++++++ .../ui/views/learn/widgets/quiz_widget.dart | 7 + .../views/learn/widgets/scene/scene_view.dart | 9 +- .../learn/widgets/scene/scene_viewmodel.dart | 79 +++--- 9 files changed, 391 insertions(+), 52 deletions(-) create mode 100644 mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 8c9894ce3..87df14898 100644 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ b/mobile-app/lib/models/learn/challenge_model.dart @@ -67,7 +67,7 @@ class Challenge { // English Challenges final FillInTheBlank? fillInTheBlank; - final EnglishAudio? audio; + final EnglishScene? audio; final Scene? scene; // Nodules for interactive challenges @@ -125,7 +125,7 @@ class Challenge { ? FillInTheBlank.fromJson(data['fillInTheBlank']) : null, audio: data['scene'] != null - ? EnglishAudio.fromJson(data['scene']['setup']['audio']) + ? EnglishScene.fromJson(data['scene']['setup']['audio']) : null, tests: (data['tests'] ?? []) .map((file) => ChallengeTest.fromJson(file)) @@ -456,11 +456,13 @@ class QuizQuestion { final String text; final List answers; final int solution; + final QuizAudioData? audioData; const QuizQuestion({ required this.text, required this.answers, required this.solution, + this.audioData, }); factory QuizQuestion.fromJson(Map data) { @@ -482,7 +484,13 @@ class QuizQuestion { allAnswers.indexWhere((a) => a.answer == data['answer']) + 1; return QuizQuestion( - text: data['text'], answers: allAnswers, solution: solutionIndex); + text: data['text'], + answers: allAnswers, + solution: solutionIndex, + audioData: data['audioData'] != null + ? QuizAudioData.fromJson(data['audioData']) + : null, + ); } } @@ -526,7 +534,7 @@ class Scene { class SceneSetup { final String background; final bool? alwaysShowDialogue; - final EnglishAudio audio; + final EnglishScene audio; final List characters; const SceneSetup({ @@ -539,7 +547,7 @@ class SceneSetup { factory SceneSetup.fromJson(Map data) { return SceneSetup( background: data['background'], - audio: EnglishAudio.fromJson(data['audio']), + audio: EnglishScene.fromJson(data['audio']), characters: data['characters'] .map( (character) => SceneCharacter.fromJson(character), @@ -642,21 +650,30 @@ class SceneDialogue { } } -class EnglishAudio { +abstract class AudioClip { + String get fileName; + String? get startTimeStamp; + String? get finishTimeStamp; +} + +class EnglishScene implements AudioClip { + @override final String fileName; final String startTime; + @override final String? startTimeStamp; + @override final String? finishTimeStamp; - const EnglishAudio({ + const EnglishScene({ required this.fileName, required this.startTime, required this.startTimeStamp, required this.finishTimeStamp, }); - factory EnglishAudio.fromJson(Map data) { - return EnglishAudio( + factory EnglishScene.fromJson(Map data) { + return EnglishScene( fileName: data['filename'], startTime: data['startTime'].toString(), startTimeStamp: data['startTimestamp']?.toString(), @@ -664,3 +681,65 @@ class EnglishAudio { ); } } + +class QuizTranscriptLine { + final String character; + final String text; + + const QuizTranscriptLine({ + required this.character, + required this.text, + }); + + factory QuizTranscriptLine.fromJson(Map data) { + return QuizTranscriptLine( + character: data['character'], + text: data['text'], + ); + } +} + +class QuizAudio implements AudioClip { + @override + final String fileName; + @override + final String? startTimeStamp; + @override + final String? finishTimeStamp; + + const QuizAudio({ + required this.fileName, + this.startTimeStamp, + this.finishTimeStamp, + }); + + factory QuizAudio.fromJson(Map data) { + return QuizAudio( + fileName: data['filename'], + startTimeStamp: data['startTimestamp']?.toString(), + finishTimeStamp: data['finishTimestamp']?.toString(), + ); + } +} + +class QuizAudioData { + final QuizAudio audio; + final List transcript; + + const QuizAudioData({ + required this.audio, + required this.transcript, + }); + + factory QuizAudioData.fromJson(Map data) { + final audioData = data['audio']; + return QuizAudioData( + audio: QuizAudio.fromJson(audioData), + transcript: (data['transcript'] as List) + .map( + (item) => QuizTranscriptLine.fromJson(item), + ) + .toList(), + ); + } +} diff --git a/mobile-app/lib/service/audio/audio_service.dart b/mobile-app/lib/service/audio/audio_service.dart index eeac96a35..42b08395b 100644 --- a/mobile-app/lib/service/audio/audio_service.dart +++ b/mobile-app/lib/service/audio/audio_service.dart @@ -239,7 +239,7 @@ class AudioPlayerHandler extends BaseAudioHandler { return 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/$fileName'; } - bool canSeek(bool forward, int currentDuration, EnglishAudio audio) { + bool canSeek(bool forward, int currentDuration, AudioClip audio) { currentDuration = currentDuration + parseTimeStamp(audio.startTimeStamp).inSeconds; @@ -252,7 +252,7 @@ class AudioPlayerHandler extends BaseAudioHandler { } } - void loadEnglishAudio(EnglishAudio audio) async { + Future loadCurriculumAudio(AudioClip audio) async { await _audioPlayer.setAudioSource( ClippingAudioSource( start: parseTimeStamp(audio.startTimeStamp), @@ -260,15 +260,13 @@ class AudioPlayerHandler extends BaseAudioHandler { ? null : parseTimeStamp(audio.finishTimeStamp), child: AudioSource.uri( - Uri.parse( - returnUrl(audio.fileName), - ), + Uri.parse(returnUrl(audio.fileName)), ), ), ); await _audioPlayer.load(); setEpisodeId = ''; - _audioType = 'english'; + _audioType = 'curriculum'; } void _notifyAudioHandlerAboutPlaybackEvents() { diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart index 383a8d185..6257d85eb 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart @@ -59,6 +59,7 @@ class QuizViewModel extends BaseViewModel { text: q.text, answers: q.answers, solution: q.solution, + audioData: q.audioData, )) .toList(); } @@ -114,6 +115,7 @@ class QuizViewModel extends BaseViewModel { text: q.text, answers: q.answers, solution: q.solution, + audioData: q.audioData, )) .toList(); diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart index edd8060d6..d9a3c83b9 100644 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart @@ -8,7 +8,7 @@ import 'package:stacked/stacked.dart'; class AudioPlayerView extends StatelessWidget { const AudioPlayerView({super.key, required this.audio}); - final EnglishAudio audio; + final EnglishScene audio; @override Widget build(BuildContext context) { @@ -16,7 +16,7 @@ class AudioPlayerView extends StatelessWidget { viewModelBuilder: () => AudioPlayerViewmodel(), onViewModelReady: (model) => { model.initPositionListener(), - model.audioService.loadEnglishAudio(audio) + model.audioService.loadCurriculumAudio(audio) }, onDispose: (model) => model.onDispose(), builder: (context, model, child) => Padding( @@ -48,7 +48,7 @@ class InnerAudioWidget extends StatelessWidget { }); final AudioPlayerViewmodel model; - final EnglishAudio audio; + final EnglishScene audio; final PlaybackState playerState; @override diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart index 1fdd82e4f..88717f7e0 100644 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart @@ -17,7 +17,7 @@ class AudioPlayerViewmodel extends BaseViewModel { Duration searchTimeStamp( bool forwards, int currentPosition, - EnglishAudio audio, + EnglishScene audio, ) { if (forwards) { return Duration( diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart new file mode 100644 index 000000000..a185975bf --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart @@ -0,0 +1,231 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/service/audio/audio_service.dart'; +import 'package:freecodecamp/ui/theme/fcc_theme.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; + +class QuizAudioPlayer extends StatefulWidget { + const QuizAudioPlayer({super.key, required this.audioData}); + + final QuizAudioData audioData; + + @override + State createState() => _QuizAudioPlayerState(); +} + +class _QuizAudioPlayerState extends State { + final audioHandler = locator().audioHandler; + final StreamController position = + StreamController.broadcast(); + bool _isLoading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _loadAudio(); + } + + Future _loadAudio() async { + try { + setState(() { + _isLoading = true; + _hasError = false; + }); + + await audioHandler.stop(); + await audioHandler.loadCurriculumAudio(widget.audioData.audio); + + // Listen to position changes + AudioService.position.listen((pos) { + if (mounted) { + position.add(pos); + } + }); + + setState(() { + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _hasError = true; + }); + } + } + + @override + void dispose() { + position.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (_hasError) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('Error loading audio'), + ), + ); + } + + return Column( + children: [ + StreamBuilder( + initialData: PlaybackState(), + stream: audioHandler.playbackState, + builder: (context, snapshot) { + final playerState = snapshot.data as PlaybackState; + + return StreamBuilder( + initialData: Duration.zero, + stream: position.stream, + builder: (context, positionSnapshot) { + if (!positionSnapshot.hasData) { + return const CircularProgressIndicator(); + } + + final currentPosition = positionSnapshot.data!; + final totalDuration = audioHandler.duration() ?? Duration.zero; + final hasZeroValue = totalDuration.inMilliseconds == 0 || + currentPosition.inMilliseconds == 0; + + return Column( + children: [ + LinearProgressIndicator( + backgroundColor: FccColors.gray75, + valueColor: const AlwaysStoppedAnimation( + FccColors.blue50, + ), + value: hasZeroValue + ? 0 + : currentPosition.inMilliseconds / + totalDuration.inMilliseconds, + minHeight: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () { + if (playerState.playing && + playerState.processingState != + AudioProcessingState.completed) { + audioHandler.pause(); + } else if (playerState.processingState == + AudioProcessingState.completed) { + audioHandler.seek(Duration.zero); + audioHandler.play(); + } else { + audioHandler.play(); + } + }, + icon: playerState.playing && + playerState.processingState != + AudioProcessingState.completed + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + ), + ], + ), + ], + ); + }, + ); + }, + ), + if (widget.audioData.transcript.isNotEmpty) ...[ + const SizedBox(height: 8), + _TranscriptWidget(transcript: widget.audioData.transcript), + ], + ], + ); + } +} + +class _TranscriptWidget extends StatefulWidget { + const _TranscriptWidget({required this.transcript}); + + final List transcript; + + @override + State<_TranscriptWidget> createState() => _TranscriptWidgetState(); +} + +class _TranscriptWidgetState extends State<_TranscriptWidget> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return ChallengeCard( + title: 'Transcript', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Show transcript', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + ), + ], + ), + ), + if (_isExpanded) ...[ + const SizedBox(height: 12), + ...widget.transcript.map((line) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: RichText( + text: TextSpan( + style: const TextStyle( + fontSize: 14, + color: FccColors.gray05, + ), + children: [ + TextSpan( + text: '${line.character}: ', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: line.text), + ], + ), + ), + ); + }), + ], + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart index 891c672e7..f7f5c5235 100644 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/quiz_audio_player.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; // Model that extends Question with selectedAnswer and validation status @@ -10,6 +11,7 @@ class QuizWidgetQuestion { final String text; final List answers; final int solution; + final QuizAudioData? audioData; int selectedAnswer; bool? isCorrect; @@ -17,6 +19,7 @@ class QuizWidgetQuestion { required this.text, required this.answers, required this.solution, + this.audioData, this.selectedAnswer = -1, this.isCorrect, }); @@ -119,6 +122,10 @@ class _QuizWidgetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...parsedQuestions[questionIndex], + if (question.audioData != null) ...[ + const SizedBox(height: 16), + QuizAudioPlayer(audioData: question.audioData!), + ], const SizedBox(height: 8), RadioGroup( groupValue: selectedAnswer, diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart index 6e98728d8..6596dda7a 100644 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart @@ -21,7 +21,7 @@ class SceneView extends StatelessWidget { viewModelBuilder: () => SceneViewModel(), onViewModelReady: (model) { model.initPositionListener(); - model.audioService.loadEnglishAudio(scene.setup.audio); + model.audioService.loadCurriculumAudio(scene.setup.audio); model.initScene(scene); }, onDispose: (model) => model.onDispose(), @@ -87,7 +87,8 @@ class _FullscreenSceneOverlayState extends State<_FullscreenSceneOverlay> { DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - WidgetsBinding.instance.addPostFrameCallback((_) => _measureControlsHeight()); + WidgetsBinding.instance + .addPostFrameCallback((_) => _measureControlsHeight()); } void _measureControlsHeight() { @@ -390,14 +391,14 @@ class _CharacterSlot extends StatelessWidget { final characterHeight = canvasHeight * scale; final characterWidth = characterHeight * (2 / 3); - final xPercent = state.position.x.toDouble() / 100; + final xPercent = state.position.x.toDouble() / 100; final yPercent = state.position.y.toDouble() / 100; final leftPos = (xPercent * canvasWidth) - (characterWidth / 2); final overflowHeight = characterHeight - canvasHeight; final bottomPos = -(overflowHeight / 2) - (yPercent * canvasHeight); - return AnimatedPositioned( + return AnimatedPositioned( duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, left: leftPos, diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart index bc9c66827..c38c44fac 100644 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart @@ -49,7 +49,8 @@ class CharacterState { } class SceneViewModel extends BaseViewModel { - static const String _cdnBase = 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/images'; + static const String _cdnBase = + 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/images'; static const int _minMouthInterval = 85; static const int _maxMouthInterval = 105; static const int _minBlinkInterval = 2000; @@ -59,7 +60,8 @@ class SceneViewModel extends BaseViewModel { final audioService = locator().audioHandler; final _sceneAssetsService = SceneAssetsService(); SceneAssets? _sceneAssets; - final StreamController position = StreamController.broadcast(); + final StreamController position = + StreamController.broadcast(); final Map _mouthAnimationTimers = {}; final Map _blinkTimers = {}; final Set _appliedCommandIndices = {}; @@ -97,7 +99,8 @@ class SceneViewModel extends BaseViewModel { // Helper methods for DRY code int _findCharacterIndex(String characterName) { - return _availableCharacters.indexWhere((c) => c.characterName == characterName); + return _availableCharacters + .indexWhere((c) => c.characterName == characterName); } void _clearTimers(Map timers) { @@ -130,13 +133,14 @@ class SceneViewModel extends BaseViewModel { await startAudio(); } - Duration searchTimeStamp(bool forwards, int currentPosition, EnglishAudio audio) { + Duration searchTimeStamp( + bool forwards, int currentPosition, EnglishScene audio) { return Duration(milliseconds: currentPosition + (forwards ? 2000 : -2)); } void initPositionListener() { AudioService.position.listen((event) { - if (position.isClosed) return; + if (position.isClosed) return; position.add(event); _updateSceneForTime(event); }); @@ -147,11 +151,12 @@ class SceneViewModel extends BaseViewModel { if (_isPlaying) { _startBlinkAnimations(); } else { - _stopAllMouthAnimations(); + _stopAllMouthAnimations(); _stopAllBlinkAnimations(); } - if (state.processingState == AudioProcessingState.completed && !_isCompleted) { + if (state.processingState == AudioProcessingState.completed && + !_isCompleted) { _handleAudioComplete(); } }); @@ -174,7 +179,8 @@ class SceneViewModel extends BaseViewModel { final command = _scene!.commands[i]; _appliedCommandIndices.add(i); - if (command.background != null && _currentBackground != command.background) { + if (command.background != null && + _currentBackground != command.background) { _currentBackground = command.background; } _updateCharacterState(command); @@ -228,7 +234,6 @@ class SceneViewModel extends BaseViewModel { showMouth: true, mouthType: 'closed', opacity: char.opacity?.toDouble() ?? 1.0, - )) .toList(); } @@ -237,7 +242,8 @@ class SceneViewModel extends BaseViewModel { if (_scene == null) return; // Apply all commands that happen before the audio starts - final audioStartTime = double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; + final audioStartTime = + double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; for (final command in _scene!.commands) { final startTime = command.startTime.toDouble(); @@ -256,10 +262,11 @@ class SceneViewModel extends BaseViewModel { _appliedCommandIndices.clear(); if (_scene != null) { - final startTime = double.parse(_scene!.setup.audio.startTime); + final startTime = double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; _audioStartOffset = startTime; if (startTime > 0) { - await Future.delayed(Duration(milliseconds: (startTime * 1000).toInt())); + await Future.delayed( + Duration(milliseconds: (startTime * 1000).toInt())); } } @@ -275,7 +282,8 @@ class SceneViewModel extends BaseViewModel { void _updateSceneForTime(Duration currentTime) { if (_scene == null || !_isPlaying || _isCompleted) return; - final currentSeconds = (currentTime.inMilliseconds / 1000) + _audioStartOffset; + final currentSeconds = + (currentTime.inMilliseconds / 1000) + _audioStartOffset; bool sceneChanged = false; final Map charactersSpeaking = {}; @@ -304,7 +312,8 @@ class SceneViewModel extends BaseViewModel { if (currentSeconds >= startTime && !_appliedCommandIndices.contains(i)) { _appliedCommandIndices.add(i); - if (command.background != null && _currentBackground != command.background) { + if (command.background != null && + _currentBackground != command.background) { _currentBackground = command.background; sceneChanged = true; } @@ -337,7 +346,8 @@ class SceneViewModel extends BaseViewModel { if (newPosition == null && newOpacity == null) return false; - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( position: newPosition, opacity: newOpacity?.toDouble(), ); @@ -345,7 +355,8 @@ class SceneViewModel extends BaseViewModel { } else { _availableCharacters.add(CharacterState( characterName: command.character, - position: command.position ?? const SceneCharacterPosition(x: 0, y: 0, z: 0), + position: + command.position ?? const SceneCharacterPosition(x: 0, y: 0, z: 0), showMouth: true, mouthType: 'closed', opacity: command.opacity?.toDouble() ?? 1.0, @@ -370,7 +381,8 @@ class SceneViewModel extends BaseViewModel { final characterIndex = _findCharacterIndex(characterName); if (characterIndex >= 0) { - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( showMouth: true, mouthType: 'open', ); @@ -378,7 +390,8 @@ class SceneViewModel extends BaseViewModel { } void scheduleMouthUpdate(String nextMouthType) { - final interval = _minMouthInterval + random.nextInt(_maxMouthInterval - _minMouthInterval + 1); + final interval = _minMouthInterval + + random.nextInt(_maxMouthInterval - _minMouthInterval + 1); _mouthAnimationTimers[characterName] = Timer( Duration(milliseconds: interval), @@ -409,7 +422,8 @@ class SceneViewModel extends BaseViewModel { final characterIndex = _findCharacterIndex(characterName); if (characterIndex >= 0) { - _availableCharacters[characterIndex] = _availableCharacters[characterIndex].copyWith( + _availableCharacters[characterIndex] = + _availableCharacters[characterIndex].copyWith( showMouth: true, mouthType: 'closed', ); @@ -434,8 +448,8 @@ class SceneViewModel extends BaseViewModel { final random = Random(); void scheduleNextBlink() { - final interval = - _minBlinkInterval + random.nextInt(_maxBlinkInterval - _minBlinkInterval + 1); + final interval = _minBlinkInterval + + random.nextInt(_maxBlinkInterval - _minBlinkInterval + 1); _blinkTimers[characterName] = Timer( Duration(milliseconds: interval), @@ -445,7 +459,8 @@ class SceneViewModel extends BaseViewModel { if (characterIndex >= 0) { // Close eyes _availableCharacters[characterIndex] = - _availableCharacters[characterIndex].copyWith(eyeState: 'closed'); + _availableCharacters[characterIndex] + .copyWith(eyeState: 'closed'); notifyListeners(); // Open eyes after blink duration @@ -503,7 +518,9 @@ class SceneViewModel extends BaseViewModel { } String getBackgroundUrl(String backgroundName) { - final cleanName = backgroundName.endsWith('.png') ? backgroundName : '$backgroundName.png'; + final cleanName = backgroundName.endsWith('.png') + ? backgroundName + : '$backgroundName.png'; if (_sceneAssets != null) { return '${_sceneAssets!.backgrounds}/$cleanName'; } @@ -511,28 +528,32 @@ class SceneViewModel extends BaseViewModel { } String getCharacterBaseUrl(String characterName) => - _getCharacterAssets(characterName)?.base ?? _buildFallbackUrl(characterName, 'base.png'); + _getCharacterAssets(characterName)?.base ?? + _buildFallbackUrl(characterName, 'base.png'); String getCharacterBrowsUrl(String characterName) => - _getCharacterAssets(characterName)?.brows ?? _buildFallbackUrl(characterName, 'brows.png'); + _getCharacterAssets(characterName)?.brows ?? + _buildFallbackUrl(characterName, 'brows.png'); String getCharacterEyesUrl(String characterName, String eyeState) { final assets = _getCharacterAssets(characterName); if (assets != null) { return eyeState == 'closed' ? assets.eyesClosed : assets.eyesOpen; } - return _buildFallbackUrl( - characterName, eyeState == 'closed' ? 'eyes-closed.png' : 'eyes-open.png'); + return _buildFallbackUrl(characterName, + eyeState == 'closed' ? 'eyes-closed.png' : 'eyes-open.png'); } - String? getCharacterGlassesUrl(String characterName) => _getCharacterAssets(characterName)?.glasses; + String? getCharacterGlassesUrl(String characterName) => + _getCharacterAssets(characterName)?.glasses; String getCharacterMouthUrl(String characterName, String mouthType) { final assets = _getCharacterAssets(characterName); if (assets != null) { return mouthType == 'open' ? assets.mouthOpen : assets.mouthClosed; } - return _buildFallbackUrl(characterName, mouthType == 'open' ? 'mouth-open.png' : 'mouth-closed.png'); + return _buildFallbackUrl(characterName, + mouthType == 'open' ? 'mouth-open.png' : 'mouth-closed.png'); } void onDispose() { From c3a9d33d2450b818f963f448fe0178b1c5ef5e5c Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:59:15 +0700 Subject: [PATCH 2/3] test code --- .../assets/test_data/quiz_with_audio.json | 229 ++++++++++++++++++ .../templates/quiz/quiz_viewmodel.dart | 23 +- mobile-app/pubspec.yaml | 1 + 3 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 mobile-app/assets/test_data/quiz_with_audio.json diff --git a/mobile-app/assets/test_data/quiz_with_audio.json b/mobile-app/assets/test_data/quiz_with_audio.json new file mode 100644 index 000000000..e1b2ba49a --- /dev/null +++ b/mobile-app/assets/test_data/quiz_with_audio.json @@ -0,0 +1,229 @@ +{ + "id": "69601914119b98f0f0c530b8", + "title": "First Day at The Office Greetings Quiz", + "challengeType": 8, + "dashedName": "en-a2-quiz-greetings-first-day-office", + "lang": "en-US", + "solutions": [], + "assignments": [], + "quizzes": [ + { + "questions": [ + { + "text": "

Which of the following is NOT a greeting?

", + "distractors": [ + "

It's a pleasure to meet you.

", + "

Welcome aboard.

", + "

Hello! Nice to meet you.

" + ], + "answer": "

See you tomorrow.

" + }, + { + "text": "

Listen to the audio and select the most appropriate answer.

", + "distractors": [ + "

Great to see you here.

", + "

Good to know. Thank you!

", + "

Sounds great!

" + ], + "answer": "

Hi, that's right! I'm Tom.

", + "audioData": { + "audio": { + "filename": "1.1-1.mp3", + "startTimestamp": 0, + "finishTimestamp": 4.2 + }, + "transcript": [ + { + "character": "Maria", + "text": "Hello. You're the new graphic designer, right? I'm Maria, the team lead." + } + ] + } + }, + { + "text": "

What is the correct way to say your job or role at work?

", + "distractors": [ + "

I'm the Maria, team lead.

", + "

I'm team lead Maria.

", + "

I'm Maria, team lead.

" + ], + "answer": "

I'm Maria, the team lead.

" + }, + { + "text": "

Who is a team lead?

", + "distractors": [ + "

A person who oversees the progress of a project.

", + "

A person who develops software or systems.

", + "

A person who works alone.

" + ], + "answer": "

A person who leads or manages a team.

" + }, + { + "text": "

Which sentence uses the correct form of to be?

", + "distractors": [ + "

I are a designer.

", + "

You am new here.

", + "

They is very attentive.

" + ], + "answer": "

She is my manager.

" + }, + { + "text": "

Which option shows the correct contraction?

", + "distractors": [ + "

you areyour

", + "

is notis'nt

", + "

it isits

" + ], + "answer": "

I amI'm

" + }, + { + "text": "

What is the correct way to say someone is from Texas?

", + "distractors": [ + "

Tom are from Texas.

", + "

Sophie am from Texas.

", + "

They is from Texas.

" + ], + "answer": "

He is from Texas.

" + }, + { + "text": "

What do you say to greet someone new in a team or company?

", + "distractors": [ + "

That's your workspace.

", + "

See you later.

", + "

Are you ready to begin?

" + ], + "answer": "

Welcome aboard.

" + }, + { + "text": "

What is the correct negative form of this sentence: I'm the new graphic designer?

", + "distractors": [ + "

I not am the new graphic designer.

", + "

I don't the new graphic designer.

", + "

I'm don't the new graphic designer.

" + ], + "answer": "

I'm not the new graphic designer.

" + }, + { + "text": "

Which word means the place where you work, especially in an office?

", + "distractors": [ + "

Standing desk

", + "

Ergonomic chair

", + "

Lunch spot

" + ], + "answer": "

Workspace

" + }, + { + "text": "

You see a laptop close to you and say: BLANK is the laptop I want.

", + "distractors": [ + "

That

", + "

These

", + "

Those

" + ], + "answer": "

This

" + }, + { + "text": "

What is the correct question form of this sentence: He's the new graphic designer?

", + "distractors": [ + "

He is the new graphic designer?

", + "

Does he the new graphic designer?

", + "

Are he the new graphic designer?

" + ], + "answer": "

Is he the new graphic designer?

" + }, + { + "text": "

When do you say: Let me introduce you to Tom?

", + "distractors": [ + "

When you want to end a conversation.

", + "

When you are talking about Tom's job.

", + "

When two people already know each other.

" + ], + "answer": "

When two people meet for the first time.

" + }, + { + "text": "

Choose the grammatically correct answer:

", + "distractors": [ + "

This is Sophie. She a developer.

", + "

This Sophie. She is a developer.

", + "

This Sophie. She a developer.

" + ], + "answer": "

This is Sophie. She is a developer.

" + }, + { + "text": "

Who is a workmate?

", + "distractors": [ + "

A company manager

", + "

A close friend

", + "

A customer

" + ], + "answer": "

Someone you work with

" + }, + { + "text": "

What is the correct way to say which department you work in?

", + "distractors": [ + "

I'm Jake at Security.

", + "

I Jake from Security.

", + "

I'm Jake in the Security.

" + ], + "answer": "

I'm Jake from Security.

" + }, + { + "text": "

How do you answer this question: Is this card contactless?

", + "distractors": [ + "

Yes, it does. / No, it doesn't.

", + "

Yes, it isn't. / No, it is.

", + "

Yes, this is. / No, this isn't.

" + ], + "answer": "

Yes, it is. / No, it isn't.

" + }, + { + "text": "

When do you say See you tomorrow?

", + "distractors": [ + "

When you meet someone for the first time.

", + "

When you say hello.

", + "

When you don't know the person.

" + ], + "answer": "

When you will meet the person again the next day.

" + }, + { + "text": "

True or false: There is no difference between a, an, and the.

", + "distractors": [ + "

That's true.

", + "

It depends on the word.

", + "

Sometimes that's true.

" + ], + "answer": "

That's false.

" + }, + { + "text": "

Which word means listening or paying attention carefully?

", + "distractors": [ + "

Inactive

", + "

Alternative

", + "

Energetic

" + ], + "answer": "

Attentive

" + } + ] + } + ], + "tests": [], + "description": "
\n

This quiz checks your understanding of greetings, introductions, job roles, and simple grammar.

\n

To pass the quiz, you must correctly answer at least 18 of the 20 questions below.

\n

Read each question and choose the correct answer. There's only one correct answer for each question.

\n
", + "translationPending": false, + "sourceLocation": "blocks/en-a2-quiz-greetings-first-day-office/69601914119b98f0f0c530b8.md", + "block": "en-a2-quiz-greetings-first-day-office", + "blockLabel": "quiz", + "blockLayout": "link", + "hasEditableBoundaries": false, + "order": 1, + "instructions": "", + "questions": [], + "superBlock": "a2-english-for-developers", + "superOrder": 7, + "challengeOrder": 0, + "isLastChallengeInBlock": true, + "required": [], + "helpCategory": "English", + "usesMultifileEditor": false, + "disableLoopProtectTests": false, + "disableLoopProtectPreview": false, + "certification": "a2-english-for-developers" +} \ No newline at end of file diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart index 6257d85eb..9ab7f1c4d 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; +import 'package:flutter/services.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/service/developer_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; import 'package:stacked/stacked.dart'; @@ -25,6 +28,16 @@ class QuizViewModel extends BaseViewModel { List get quizQuestions => _quizQuestions; final LearnService learnService = locator(); + final DeveloperService developerService = locator(); + + Future loadTestQuizData() async { + String jsonString = await rootBundle.loadString( + 'assets/test_data/quiz_with_audio.json', + ); + + var decodedJson = jsonDecode(jsonString); + return Challenge.fromJson(decodedJson); + } set setQuizQuestions(List questions) { _quizQuestions = questions; @@ -46,7 +59,15 @@ class QuizViewModel extends BaseViewModel { notifyListeners(); } - void initChallenge(Challenge challenge) { + Future initChallenge(Challenge challenge) async { + // In development mode, load test data with audio instead of real challenge + final isDevMode = await developerService.developmentMode(); + print('Development mode: $isDevMode'); + + if (isDevMode) { + challenge = await loadTestQuizData(); + } + // Randomly select a question set from challenge.quizzes final questionSet = (challenge.quizzes != null && challenge.quizzes!.isNotEmpty) diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 600ad62c1..3a10b7883 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -86,6 +86,7 @@ flutter: - assets/sql/ - assets/test_data/news_post.json - assets/test_data/news_feed.json + - assets/test_data/quiz_with_audio.json - assets/database/bookmarked-article.db - assets/learn/ - assets/test_runner/dist/ From 4b89805d45a5704ce45d7a4c2728905f41201440 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:05:49 +0700 Subject: [PATCH 3/3] Revert "test code" This reverts commit c3a9d33d2450b818f963f448fe0178b1c5ef5e5c. --- .../assets/test_data/quiz_with_audio.json | 229 ------------------ .../templates/quiz/quiz_viewmodel.dart | 23 +- mobile-app/pubspec.yaml | 1 - 3 files changed, 1 insertion(+), 252 deletions(-) delete mode 100644 mobile-app/assets/test_data/quiz_with_audio.json diff --git a/mobile-app/assets/test_data/quiz_with_audio.json b/mobile-app/assets/test_data/quiz_with_audio.json deleted file mode 100644 index e1b2ba49a..000000000 --- a/mobile-app/assets/test_data/quiz_with_audio.json +++ /dev/null @@ -1,229 +0,0 @@ -{ - "id": "69601914119b98f0f0c530b8", - "title": "First Day at The Office Greetings Quiz", - "challengeType": 8, - "dashedName": "en-a2-quiz-greetings-first-day-office", - "lang": "en-US", - "solutions": [], - "assignments": [], - "quizzes": [ - { - "questions": [ - { - "text": "

Which of the following is NOT a greeting?

", - "distractors": [ - "

It's a pleasure to meet you.

", - "

Welcome aboard.

", - "

Hello! Nice to meet you.

" - ], - "answer": "

See you tomorrow.

" - }, - { - "text": "

Listen to the audio and select the most appropriate answer.

", - "distractors": [ - "

Great to see you here.

", - "

Good to know. Thank you!

", - "

Sounds great!

" - ], - "answer": "

Hi, that's right! I'm Tom.

", - "audioData": { - "audio": { - "filename": "1.1-1.mp3", - "startTimestamp": 0, - "finishTimestamp": 4.2 - }, - "transcript": [ - { - "character": "Maria", - "text": "Hello. You're the new graphic designer, right? I'm Maria, the team lead." - } - ] - } - }, - { - "text": "

What is the correct way to say your job or role at work?

", - "distractors": [ - "

I'm the Maria, team lead.

", - "

I'm team lead Maria.

", - "

I'm Maria, team lead.

" - ], - "answer": "

I'm Maria, the team lead.

" - }, - { - "text": "

Who is a team lead?

", - "distractors": [ - "

A person who oversees the progress of a project.

", - "

A person who develops software or systems.

", - "

A person who works alone.

" - ], - "answer": "

A person who leads or manages a team.

" - }, - { - "text": "

Which sentence uses the correct form of to be?

", - "distractors": [ - "

I are a designer.

", - "

You am new here.

", - "

They is very attentive.

" - ], - "answer": "

She is my manager.

" - }, - { - "text": "

Which option shows the correct contraction?

", - "distractors": [ - "

you areyour

", - "

is notis'nt

", - "

it isits

" - ], - "answer": "

I amI'm

" - }, - { - "text": "

What is the correct way to say someone is from Texas?

", - "distractors": [ - "

Tom are from Texas.

", - "

Sophie am from Texas.

", - "

They is from Texas.

" - ], - "answer": "

He is from Texas.

" - }, - { - "text": "

What do you say to greet someone new in a team or company?

", - "distractors": [ - "

That's your workspace.

", - "

See you later.

", - "

Are you ready to begin?

" - ], - "answer": "

Welcome aboard.

" - }, - { - "text": "

What is the correct negative form of this sentence: I'm the new graphic designer?

", - "distractors": [ - "

I not am the new graphic designer.

", - "

I don't the new graphic designer.

", - "

I'm don't the new graphic designer.

" - ], - "answer": "

I'm not the new graphic designer.

" - }, - { - "text": "

Which word means the place where you work, especially in an office?

", - "distractors": [ - "

Standing desk

", - "

Ergonomic chair

", - "

Lunch spot

" - ], - "answer": "

Workspace

" - }, - { - "text": "

You see a laptop close to you and say: BLANK is the laptop I want.

", - "distractors": [ - "

That

", - "

These

", - "

Those

" - ], - "answer": "

This

" - }, - { - "text": "

What is the correct question form of this sentence: He's the new graphic designer?

", - "distractors": [ - "

He is the new graphic designer?

", - "

Does he the new graphic designer?

", - "

Are he the new graphic designer?

" - ], - "answer": "

Is he the new graphic designer?

" - }, - { - "text": "

When do you say: Let me introduce you to Tom?

", - "distractors": [ - "

When you want to end a conversation.

", - "

When you are talking about Tom's job.

", - "

When two people already know each other.

" - ], - "answer": "

When two people meet for the first time.

" - }, - { - "text": "

Choose the grammatically correct answer:

", - "distractors": [ - "

This is Sophie. She a developer.

", - "

This Sophie. She is a developer.

", - "

This Sophie. She a developer.

" - ], - "answer": "

This is Sophie. She is a developer.

" - }, - { - "text": "

Who is a workmate?

", - "distractors": [ - "

A company manager

", - "

A close friend

", - "

A customer

" - ], - "answer": "

Someone you work with

" - }, - { - "text": "

What is the correct way to say which department you work in?

", - "distractors": [ - "

I'm Jake at Security.

", - "

I Jake from Security.

", - "

I'm Jake in the Security.

" - ], - "answer": "

I'm Jake from Security.

" - }, - { - "text": "

How do you answer this question: Is this card contactless?

", - "distractors": [ - "

Yes, it does. / No, it doesn't.

", - "

Yes, it isn't. / No, it is.

", - "

Yes, this is. / No, this isn't.

" - ], - "answer": "

Yes, it is. / No, it isn't.

" - }, - { - "text": "

When do you say See you tomorrow?

", - "distractors": [ - "

When you meet someone for the first time.

", - "

When you say hello.

", - "

When you don't know the person.

" - ], - "answer": "

When you will meet the person again the next day.

" - }, - { - "text": "

True or false: There is no difference between a, an, and the.

", - "distractors": [ - "

That's true.

", - "

It depends on the word.

", - "

Sometimes that's true.

" - ], - "answer": "

That's false.

" - }, - { - "text": "

Which word means listening or paying attention carefully?

", - "distractors": [ - "

Inactive

", - "

Alternative

", - "

Energetic

" - ], - "answer": "

Attentive

" - } - ] - } - ], - "tests": [], - "description": "
\n

This quiz checks your understanding of greetings, introductions, job roles, and simple grammar.

\n

To pass the quiz, you must correctly answer at least 18 of the 20 questions below.

\n

Read each question and choose the correct answer. There's only one correct answer for each question.

\n
", - "translationPending": false, - "sourceLocation": "blocks/en-a2-quiz-greetings-first-day-office/69601914119b98f0f0c530b8.md", - "block": "en-a2-quiz-greetings-first-day-office", - "blockLabel": "quiz", - "blockLayout": "link", - "hasEditableBoundaries": false, - "order": 1, - "instructions": "", - "questions": [], - "superBlock": "a2-english-for-developers", - "superOrder": 7, - "challengeOrder": 0, - "isLastChallengeInBlock": true, - "required": [], - "helpCategory": "English", - "usesMultifileEditor": false, - "disableLoopProtectTests": false, - "disableLoopProtectPreview": false, - "certification": "a2-english-for-developers" -} \ No newline at end of file diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart index 9ab7f1c4d..6257d85eb 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart @@ -1,8 +1,5 @@ -import 'dart:convert'; -import 'package:flutter/services.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/developer_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; import 'package:stacked/stacked.dart'; @@ -28,16 +25,6 @@ class QuizViewModel extends BaseViewModel { List get quizQuestions => _quizQuestions; final LearnService learnService = locator(); - final DeveloperService developerService = locator(); - - Future loadTestQuizData() async { - String jsonString = await rootBundle.loadString( - 'assets/test_data/quiz_with_audio.json', - ); - - var decodedJson = jsonDecode(jsonString); - return Challenge.fromJson(decodedJson); - } set setQuizQuestions(List questions) { _quizQuestions = questions; @@ -59,15 +46,7 @@ class QuizViewModel extends BaseViewModel { notifyListeners(); } - Future initChallenge(Challenge challenge) async { - // In development mode, load test data with audio instead of real challenge - final isDevMode = await developerService.developmentMode(); - print('Development mode: $isDevMode'); - - if (isDevMode) { - challenge = await loadTestQuizData(); - } - + void initChallenge(Challenge challenge) { // Randomly select a question set from challenge.quizzes final questionSet = (challenge.quizzes != null && challenge.quizzes!.isNotEmpty) diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 3a10b7883..600ad62c1 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -86,7 +86,6 @@ flutter: - assets/sql/ - assets/test_data/news_post.json - assets/test_data/news_feed.json - - assets/test_data/quiz_with_audio.json - assets/database/bookmarked-article.db - assets/learn/ - assets/test_runner/dist/