diff --git a/mobile-app/devtools_options.yaml b/mobile-app/devtools_options.yaml new file mode 100644 index 000000000..fa0b357c4 --- /dev/null +++ b/mobile-app/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 5a2bc8772..2270040c0 100644 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ b/mobile-app/lib/models/learn/challenge_model.dart @@ -37,6 +37,10 @@ class Challenge { final List tests; final List files; + // English Challenges + final FillInTheBlank? fillInTheBlank; + final EnglishAudio? audio; + // Challenge Type 11 - Video // TODO: Renamed to questions and its an array of questions Question? question; @@ -58,6 +62,8 @@ class Challenge { required this.files, this.question, this.assignments, + this.fillInTheBlank, + this.audio, }); factory Challenge.fromJson(Map data) { @@ -71,6 +77,12 @@ class Challenge { superBlock: data['superBlock'], videoId: data['videoId'], challengeType: data['challengeType'], + fillInTheBlank: data['fillInTheBlank'] != null + ? FillInTheBlank.fromJson(data['fillInTheBlank']) + : null, + audio: data['scene'] != null + ? EnglishAudio.fromJson(data['scene']['setup']['audio']) + : null, tests: (data['tests'] ?? []) .map((file) => ChallengeTest.fromJson(file)) .toList(), @@ -145,7 +157,9 @@ class Question { return Question( text: data['text'], answers: (data['answers'] ?? []) - .map((answer) => Answer.fromJson(answer)) + .map( + (answer) => Answer.fromJson(answer), + ) .toList(), solution: data['solution'], ); @@ -220,3 +234,61 @@ class ChallengeFile { ); } } + +class FillInTheBlank { + final String sentence; + final List blanks; + + const FillInTheBlank({required this.sentence, required this.blanks}); + + factory FillInTheBlank.fromJson(Map data) { + return FillInTheBlank( + sentence: data['sentence'], + blanks: data['blanks'] + .map( + (blank) => Blank.fromJson(blank), + ) + .toList(), + ); + } +} + +class Blank { + final String answer; + final String feedback; + + const Blank({ + required this.answer, + required this.feedback, + }); + + factory Blank.fromJson(Map data) { + return Blank( + answer: data['answer'], + feedback: data['feedback'] ?? '', + ); + } +} + +class EnglishAudio { + final String fileName; + final String startTime; + final String startTimeStamp; + final String finishTimeStamp; + + const EnglishAudio({ + required this.fileName, + required this.startTime, + required this.startTimeStamp, + required this.finishTimeStamp, + }); + + factory EnglishAudio.fromJson(Map data) { + return EnglishAudio( + fileName: data['filename'], + startTime: data['startTime'].toString(), + startTimeStamp: data['startTimestamp'].toString(), + finishTimeStamp: data['finishTimestamp'].toString(), + ); + } +} diff --git a/mobile-app/lib/models/learn/curriculum_model.dart b/mobile-app/lib/models/learn/curriculum_model.dart index 8f8063792..b47baeb67 100644 --- a/mobile-app/lib/models/learn/curriculum_model.dart +++ b/mobile-app/lib/models/learn/curriculum_model.dart @@ -69,7 +69,12 @@ class Block { }); static bool checkIfStepBased(String superblock) { - return superblock == '2022/responsive-web-design'; + List stepbased = [ + '2022/responsive-web-design', + 'a2-english-for-developers' + ]; + + return stepbased.contains(superblock); } factory Block.fromJson( diff --git a/mobile-app/lib/service/audio/audio_service.dart b/mobile-app/lib/service/audio/audio_service.dart index 9715f30d6..994d7649d 100644 --- a/mobile-app/lib/service/audio/audio_service.dart +++ b/mobile-app/lib/service/audio/audio_service.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:freecodecamp/models/code-radio/code_radio_model.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/podcasts/episodes_model.dart'; import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; import 'package:just_audio/just_audio.dart'; @@ -68,6 +69,7 @@ class AudioPlayerHandler extends BaseAudioHandler { @override Future stop() async { await _audioPlayer.stop(); + _audioType = ''; return super.stop(); } @@ -82,8 +84,9 @@ class AudioPlayerHandler extends BaseAudioHandler { return super.onTaskRemoved(); } - // @override - // Future playFromUri() async {} + Duration? duration() { + return _audioPlayer.duration; + } Future loadEpisode( Episodes episode, @@ -171,6 +174,50 @@ class AudioPlayerHandler extends BaseAudioHandler { } } + Duration parseTimeStamp(String timeStamp) { + if (timeStamp == '0') { + return const Duration(milliseconds: 0); + } + return Duration( + milliseconds: (double.parse(timeStamp) * 1000).round(), + ); + } + + // TODO: Move to a common constants like file for curriculum stuff + String returnUrl(String fileName) { + return 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/$fileName'; + } + + bool canSeek(bool forward, int currentDuration, EnglishAudio audio) { + currentDuration = + currentDuration + parseTimeStamp(audio.startTimeStamp).inSeconds; + + if (forward) { + return currentDuration + 2 < + parseTimeStamp(audio.finishTimeStamp).inSeconds; + } else { + return currentDuration - 2 > + parseTimeStamp(audio.startTimeStamp).inSeconds; + } + } + + void loadEnglishAudio(EnglishAudio audio) async { + _audioPlayer.setAudioSource( + ClippingAudioSource( + start: parseTimeStamp(audio.startTimeStamp), + end: parseTimeStamp(audio.finishTimeStamp), + child: AudioSource.uri( + Uri.parse( + returnUrl(audio.fileName), + ), + ), + ), + ); + await _audioPlayer.load(); + setEpisodeId = ''; + _audioType = 'english'; + } + void _notifyAudioHandlerAboutPlaybackEvents() { _audioPlayer.playbackEventStream.listen( (PlaybackEvent event) { diff --git a/mobile-app/lib/ui/views/learn/block/block_view.dart b/mobile-app/lib/ui/views/learn/block/block_view.dart index 478ce8f53..70e9670fc 100644 --- a/mobile-app/lib/ui/views/learn/block/block_view.dart +++ b/mobile-app/lib/ui/views/learn/block/block_view.dart @@ -41,6 +41,9 @@ class BlockView extends StatelessWidget { bool isCertification = block.challenges.length == 1 && block.superBlock.dashedName != 'the-odin-project'; + bool isDialogue = + block.superBlock.dashedName == 'a2-english-for-developers'; + int calculateProgress = (model.challengesCompleted / block.challenges.length * 100).round(); @@ -91,7 +94,15 @@ class BlockView extends StatelessWidget { model: model, block: block, ), - if (!isCertification && isStepBased) ...[ + if (isDialogue) ...[ + buildDivider(), + dialogueWidget( + block.challenges, + context, + model, + ) + ], + if (!isCertification && isStepBased && !isDialogue) ...[ buildDivider(), gridWidget(context, model) ], @@ -112,6 +123,74 @@ class BlockView extends StatelessWidget { ); } + Widget dialogueWidget( + List challenges, + BuildContext context, + BlockViewModel model, + ) { + List> structure = []; + + List dialogueHeaders = []; + int dialogueIndex = 0; + + dialogueHeaders.add(challenges[0]); + structure.add([]); + + for (int i = 1; i < challenges.length; i++) { + if (challenges[i].title.contains('Dialogue')) { + structure.add([]); + dialogueHeaders.add(challenges[i]); + dialogueIndex++; + } else { + structure[dialogueIndex].add(challenges[i]); + } + } + return Column( + children: [ + ...List.generate(structure.length, (step) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + dialogueHeaders[step].title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + GridView.count( + physics: const ClampingScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.all(16), + crossAxisCount: (MediaQuery.of(context).size.width / 70 - + MediaQuery.of(context).viewPadding.horizontal) + .round(), + children: List.generate( + structure[step].length, + (index) { + return Center( + child: ChallengeTile( + block: block, + model: model, + challengeId: structure[step][index].id, + step: int.parse( + structure[step][index].title.split('Task')[1], + ), + isDowloaded: false, + ), + ); + }, + ), + ), + ], + ); + }) + ], + ); + } + Widget gridWidget(BuildContext context, BlockViewModel model) { return SizedBox( height: 300, @@ -133,7 +212,8 @@ class BlockView extends StatelessWidget { child: ChallengeTile( block: block, model: model, - step: step, + step: step + 1, + challengeId: block.challengeTiles[step].id, isDowloaded: (snapshot.data is bool ? snapshot.data as bool : false), @@ -271,18 +351,18 @@ class ChallengeTile extends StatelessWidget { required this.model, required this.step, required this.isDowloaded, + required this.challengeId, }) : super(key: key); final Block block; final BlockViewModel model; final int step; final bool isDowloaded; + final String challengeId; @override Widget build(BuildContext context) { - bool isCompleted = model.completedChallenge( - block.challengeTiles[step].id, - ); + bool isCompleted = model.completedChallenge(challengeId); return GridTile( child: Container( @@ -304,8 +384,6 @@ class ChallengeTile extends StatelessWidget { width: 70, child: InkWell( onTap: () async { - String challengeId = block.challengeTiles[step].id; - String url = LearnService.baseUrl; String fullUrl = @@ -319,7 +397,7 @@ class ChallengeTile extends StatelessWidget { }, child: Center( child: Text( - (step + 1).toString(), + step.toString(), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 18, diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart index 7eb3d9217..9b8058f5f 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart @@ -6,7 +6,8 @@ import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/odin/odin_view.dart'; +import 'package:freecodecamp/ui/views/learn/challenge/templates/english/english_view.dart'; +import 'package:freecodecamp/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart'; import 'package:freecodecamp/ui/views/learn/challenge/templates/python-project/python_project_view.dart'; import 'package:freecodecamp/ui/views/learn/challenge/templates/python/python_view.dart'; import 'package:freecodecamp/ui/views/learn/widgets/console/console_view.dart'; @@ -60,13 +61,21 @@ class ChallengeView extends StatelessWidget { challengesCompleted: challengesCompleted, currentChallengeNum: currChallengeNum, ); - } else if (challenge.challengeType == 15) { - return OdinView( + } else if (challenge.challengeType == 15 || + challenge.challengeType == 19) { + return MultipleChoiceView( challenge: challenge, block: block, challengesCompleted: challengesCompleted, currentChallengeNum: currChallengeNum, ); + } else if (challenge.challengeType == 22 || + challenge.challengeType == 21) { + return EnglishView( + challenge: challenge, + currentChallengeNum: currChallengeNum, + block: block, + ); } else { ChallengeFile currFile = model.currentFile(challenge); diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart index cec2178b4..262867bf8 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart @@ -221,15 +221,13 @@ class ChallengeViewModel extends BaseViewModel { ) async { setupDialogUi(); + List nonEditorTypes = [10, 11, 15, 19, 21, 22]; + setChallenge = learnOfflineService.getChallenge(url, challengeId); Challenge challenge = await _challenge!; learnService.setLastVisitedChallenge(url, block); - - if (challenge.challengeType == 11 || - challenge.challengeType == 10 || - challenge.challengeType == 15) { - } else { + if (!nonEditorTypes.contains(challenge.challengeType)) { List currentEditedChallenge = challenge.files .where((element) => element.editableRegionBoundaries.isNotEmpty) .toList(); diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart new file mode 100644 index 000000000..ca1fb23f4 --- /dev/null +++ b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; + +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/models/learn/curriculum_model.dart'; +import 'package:freecodecamp/ui/views/learn/challenge/templates/english/english_viewmodel.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_view.dart'; +import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; +import 'package:stacked/stacked.dart'; + +class EnglishView extends StatelessWidget { + const EnglishView({ + Key? key, + required this.challenge, + required this.block, + required this.currentChallengeNum, + }) : super(key: key); + + final Challenge challenge; + final Block block; + final int currentChallengeNum; + + @override + Widget build(BuildContext context) { + HTMLParser parser = HTMLParser(context: context); + + int numberOfDialogueHeaders = block.challenges + .where((challenge) => challenge.title.contains('Dialogue')) + .length; + + return ViewModelBuilder.reactive( + viewModelBuilder: () => EnglishViewModel(), + builder: (context, model, child) { + return PopScope( + canPop: true, + onPopInvokedWithResult: (bool didPop, dynamic result) { + model.learnService.updateProgressOnPop(context, block); + }, + child: Scaffold( + persistentFooterAlignment: AlignmentDirectional.topStart, + appBar: AppBar( + title: Text( + '${challenge.title} of ${block.challenges.length - numberOfDialogueHeaders}', + ), + ), + body: SafeArea( + child: ListView( + children: [ + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + challenge.title, + style: TextStyle( + fontSize: FontSize.large.value, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + Container( + margin: const EdgeInsets.all(8), + color: const Color(0xFF0a0a23), + child: Row( + children: [ + Expanded( + child: Column( + children: parser.parse( + challenge.description, + ), + ), + ) + ], + ), + ), + ], + ), + if (challenge.audio != null) ...[ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Listen to the Audio', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: FontSize.large.value, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + Container( + color: const Color(0xFF0a0a23), + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.all(8), + child: AudioPlayerView( + audio: challenge.audio!, + ), + ), + ], + if (model.feedback.isNotEmpty) + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Feedback', + style: TextStyle( + fontSize: FontSize.large.value, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + Container( + color: const Color(0xFF0a0a23), + margin: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: parser.parse(model.feedback), + ), + ), + ) + ], + ), + ), + ], + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Fill in the Blanks', + style: TextStyle( + fontSize: FontSize.large.value, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + Container( + color: const Color(0xFF0a0a23), + margin: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Wrap( + children: model.getFillInBlankWidgets( + challenge, + context, + ), + ), + ), + ) + ], + ), + ), + Row( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.all(8), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 50), + backgroundColor: + const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + side: BorderSide( + width: 2, + color: Colors.white, + ), + ), + ), + onPressed: model.allInputsCorrect + ? () => model.learnService + .goToNextChallenge( + block.challenges.length, + currentChallengeNum, + challenge, + block) + : () => {model.checkAnswers(challenge)}, + child: Text( + model.allInputsCorrect + ? 'Go to Next Challenge' + : 'Check Answers', + style: const TextStyle(fontSize: 20), + ), + ), + ), + ), + ], + ), + ], + ) + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart new file mode 100644 index 000000000..99a4f92f9 --- /dev/null +++ b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/models/learn/curriculum_model.dart'; +import 'package:freecodecamp/service/learn/learn_offline_service.dart'; +import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/ui/views/learn/superblock/superblock_view.dart'; +import 'package:stacked/stacked.dart'; + +class EnglishViewModel extends BaseViewModel { + final LearnOfflineService learnOfflineService = + locator(); + + final LearnService learnService = locator(); + + Map _currentBlankValues = {}; + Map get currentBlankValues => _currentBlankValues; + + Map _inputValuesCorrect = {}; + Map get inputValuesCorrect => _inputValuesCorrect; + + String _feedback = ''; + String get feedback => _feedback; + + bool _allInputsCorrect = false; + bool get allInputsCorrect => _allInputsCorrect; + + final StreamController> fills = + StreamController>.broadcast(); + + set setCurrentBlankValues(Map value) { + _currentBlankValues = value; + notifyListeners(); + } + + set setInptuValuesCorrect(Map value) { + _inputValuesCorrect = value; + notifyListeners(); + } + + set setAllInputsCorrect(bool value) { + _allInputsCorrect = value; + notifyListeners(); + } + + set setFeedback(String value) { + _feedback = value; + notifyListeners(); + } + + void initBlankInputStreamListener() { + fills.stream.listen((Map event) { + setCurrentBlankValues = event; + }); + } + + double calculateTextWidth(String text, TextStyle style) { + final TextPainter textPainter = TextPainter( + text: TextSpan(text: text.replaceAll('BLANK', ''), style: style), + textDirection: TextDirection.ltr, + )..layout(); + return textPainter.size.width; + } + + void checkAnswers(Challenge challenge) { + List inputKeys = currentBlankValues.keys.toList(); + List inputValues = currentBlankValues.values.toList(); + + Map correctIncorrect = {}; + + for (int i = 0; i < inputKeys.length; i++) { + if (challenge.fillInTheBlank == null) break; + inputValues[i] = inputValues[i].trim(); + + bool value = inputValues[i] == challenge.fillInTheBlank!.blanks[i].answer; + correctIncorrect['blank_correct_$i'] = value; + } + + setInptuValuesCorrect = correctIncorrect; + + setAllInputsCorrect = correctIncorrect.values.every( + (value) => value == true, + ); + + if (!allInputsCorrect) { + int firstIncorrectIndex = correctIncorrect.values.toList().indexOf(false); + + if (firstIncorrectIndex != -1) { + Blank blank = challenge.fillInTheBlank!.blanks[firstIncorrectIndex]; + setFeedback = blank.feedback; + } + } else { + setFeedback = ''; + } + } + + OutlineInputBorder handleInputBorderColor(int inputIndex) { + if (inputValuesCorrect.isEmpty) { + return const OutlineInputBorder( + borderSide: BorderSide( + color: Colors.white, + ), + ); + } + + if (inputValuesCorrect['blank_correct_$inputIndex'] == true) { + return const OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: Colors.green, + ), + ); + } else { + return const OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: Colors.red, + ), + ); + } + } + + List getFillInBlankWidgets( + Challenge challenge, + BuildContext context, + ) { + List widgets = []; + List words = challenge.fillInTheBlank!.sentence.split(' '); + + int blankIndex = 0; + + for (String word in words) { + if (word.contains('BLANK')) { + String uniqueId = 'blank_$blankIndex'; + + if (currentBlankValues[uniqueId] == null) { + currentBlankValues.addAll({uniqueId: ''}); + } + + // The blank word is sometimes concatenated with the previous or next word + List splitWord = word.split('BLANK'); + + if (splitWord.isNotEmpty) { + widgets.add( + Text( + splitWord[0].replaceAll('

', ''), + style: const TextStyle(fontSize: 20, letterSpacing: 0), + ), + ); + } + + widgets.add( + Container( + margin: const EdgeInsets.only( + left: 5, + right: 5, + ), + width: calculateTextWidth( + challenge.fillInTheBlank!.blanks[blankIndex].answer, + const TextStyle(fontSize: 20), + ) + + 20, + child: TextFormField( + initialValue: currentBlankValues[uniqueId], + onChanged: (value) { + Map local = currentBlankValues; + local[uniqueId] = value; + fills.add(local); + }, + smartQuotesType: SmartQuotesType.disabled, + spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + autocorrect: false, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(3), + focusedBorder: handleInputBorderColor(blankIndex), + isDense: true, + enabledBorder: handleInputBorderColor(blankIndex), + ), + ), + ), + ); + + if (splitWord.length > 1) { + widgets.add( + Text( + splitWord[splitWord.length - 1].replaceAll('

', ''), + style: const TextStyle(fontSize: 20, letterSpacing: 0), + ), + ); + } + + blankIndex++; + } else { + widgets.add( + Padding( + padding: const EdgeInsets.only(right: 5), + child: Text( + word.replaceAll(RegExp('

|

'), ''), + style: const TextStyle(fontSize: 20, letterSpacing: 0), + ), + ), + ); + } + } + return widgets; + } + + void updateProgressOnPop(BuildContext context, Block block) async { + learnOfflineService.hasInternet().then( + (value) => Navigator.pushReplacement( + context, + PageRouteBuilder( + transitionDuration: Duration.zero, + pageBuilder: ( + context, + animation1, + animation2, + ) => + SuperBlockView( + superBlockDashedName: block.superBlock.dashedName, + superBlockName: block.superBlock.name, + hasInternet: value, + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart similarity index 83% rename from mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_view.dart rename to mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart index 268150fa4..3e7470dc7 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart @@ -3,14 +3,15 @@ import 'package:flutter_html/flutter_html.dart'; import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/odin/odin_viewmodel.dart'; +import 'package:freecodecamp/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_view.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; import 'package:stacked/stacked.dart'; import 'package:youtube_player_iframe/youtube_player_iframe.dart'; -class OdinView extends StatelessWidget { - const OdinView({ +class MultipleChoiceView extends StatelessWidget { + const MultipleChoiceView({ Key? key, required this.challenge, required this.block, @@ -27,8 +28,8 @@ class OdinView extends StatelessWidget { Widget build(BuildContext context) { HTMLParser parser = HTMLParser(context: context); - return ViewModelBuilder.reactive( - viewModelBuilder: () => OdinViewModel(), + return ViewModelBuilder.reactive( + viewModelBuilder: () => MultipleChoiceViewmodel(), onViewModelReady: (model) => model.initChallenge(challenge), builder: (context, model, child) { YoutubePlayerController controller = @@ -42,6 +43,18 @@ class OdinView extends StatelessWidget { ), ); + int numberOfDialogueHeaders = block.challenges + .where((challenge) => challenge.title.contains('Dialogue')) + .length; + + String handleChallengeTitle() { + if (challenge.title.contains('Task')) { + return '${challenge.title} of ${block.challenges.length - numberOfDialogueHeaders} Tasks'; + } else { + return 'Question ${challenge.title} of ${block.challenges.length - numberOfDialogueHeaders} Questions'; + } + } + return PopScope( canPop: true, onPopInvokedWithResult: (bool didPop, dynamic result) { @@ -50,7 +63,7 @@ class OdinView extends StatelessWidget { child: Scaffold( appBar: AppBar( title: Text( - '$currentChallengeNum of ${block.challenges.length} Questions', + handleChallengeTitle(), ), ), body: SafeArea( @@ -85,6 +98,28 @@ class OdinView extends StatelessWidget { ...parser.parse( challenge.description, ), + if (challenge.audio != null) ...[ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Listen to the Audio', + style: TextStyle( + fontSize: FontSize.large.value, + fontWeight: FontWeight.bold, + fontFamily: 'Inter', + ), + ), + ), + Container( + color: const Color(0xFF0a0a23), + height: 104, + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.all(8), + child: AudioPlayerView( + audio: challenge.audio!, + ), + ) + ], if (challenge.assignments != null && challenge.assignments!.isNotEmpty) ...[ buildDivider(), @@ -154,7 +189,7 @@ class OdinView extends StatelessWidget { Container assignmentTile( String assignment, int ind, - OdinViewModel model, + MultipleChoiceViewmodel model, BuildContext context, ) { HTMLParser parser = HTMLParser(context: context); @@ -210,7 +245,7 @@ class OdinView extends StatelessWidget { Container questionOption( MapEntry answerObj, - OdinViewModel model, + MultipleChoiceViewmodel model, BuildContext context, ) { HTMLParser parser = HTMLParser(context: context); diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart similarity index 95% rename from mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_viewmodel.dart rename to mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart index 519ab6f10..30b9b7aad 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/odin/odin_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart @@ -3,7 +3,7 @@ import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; import 'package:stacked/stacked.dart'; -class OdinViewModel extends BaseViewModel { +class MultipleChoiceViewmodel extends BaseViewModel { int _currentChoice = -1; int get currentChoice => _currentChoice; 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 new file mode 100644 index 000000000..68f1d2982 --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart @@ -0,0 +1,166 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_viewmodel.dart'; +import 'package:stacked/stacked.dart'; + +class AudioPlayerView extends StatelessWidget { + const AudioPlayerView({Key? key, required this.audio}) : super(key: key); + + final EnglishAudio audio; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => AudioPlayerViewmodel(), + onViewModelReady: (model) => { + model.initPositionListener(), + model.audioService.loadEnglishAudio(audio) + }, + onDispose: (model) => model.onDispose(), + builder: (context, model, child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + child: StreamBuilder( + stream: model.audioService.playbackState, + builder: (context, snapshot) { + if (snapshot.hasData) { + final playerState = snapshot.data as PlaybackState; + + List validStates = [ + AudioProcessingState.completed, + AudioProcessingState.idle, + AudioProcessingState.loading, + AudioProcessingState.ready, + ]; + + if (validStates.contains(playerState.processingState)) { + return InnerAudioWidget( + model: model, + audio: audio, + playerState: playerState, + ); + } + } + + return const CircularProgressIndicator(); + }, + ), + ), + ); + } +} + +class InnerAudioWidget extends StatelessWidget { + const InnerAudioWidget({ + super.key, + required this.model, + required this.audio, + required this.playerState, + }); + + final AudioPlayerViewmodel model; + final EnglishAudio audio; + final PlaybackState playerState; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: Duration.zero, + stream: model.position.stream, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const CircularProgressIndicator(); + } + + if (snapshot.hasData) { + final position = snapshot.data as Duration; + + bool canSeekForward = model.audioService.canSeek( + true, + position.inSeconds, + audio, + ); + + bool canSeekBackward = model.audioService.canSeek( + false, + position.inSeconds, + audio, + ); + + Duration? totalDuration = model.audioService.duration(); + + return Column( + children: [ + if (totalDuration != null) + LinearProgressIndicator( + value: position.inMilliseconds / totalDuration.inMilliseconds, + minHeight: 8, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: canSeekBackward + ? () { + model.audioService.seek( + model.searchTimeStamp( + false, + position.inSeconds, + audio, + ), + ); + } + : null, + icon: const Icon(Icons.skip_previous), + ), + IconButton( + onPressed: () { + if (playerState.playing && + playerState.processingState != + AudioProcessingState.completed) { + model.audioService.pause(); + } else if (playerState.processingState == + AudioProcessingState.completed) { + model.audioService.seek( + model.searchTimeStamp( + false, + position.inSeconds, + audio, + ), + ); + model.audioService.play(); + } else { + model.audioService.play(); + } + }, + icon: playerState.playing && + playerState.processingState != + AudioProcessingState.completed + ? const Icon(Icons.pause) + : const Icon(Icons.play_arrow), + ), + IconButton( + onPressed: canSeekForward + ? () { + model.audioService.seek( + model.searchTimeStamp( + true, + position.inSeconds, + audio, + ), + ); + } + : null, + icon: const Icon(Icons.skip_next), + ), + ], + ), + ], + ); + } + + return const CircularProgressIndicator(); + }, + ); + } +} 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 new file mode 100644 index 000000000..1fdd82e4f --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.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:stacked/stacked.dart'; + +class AudioPlayerViewmodel extends BaseViewModel { + final audioService = locator().audioHandler; + + StreamController position = StreamController.broadcast(); + + Duration? _totalDuration; + Duration? get totalDuration => _totalDuration; + + Duration searchTimeStamp( + bool forwards, + int currentPosition, + EnglishAudio audio, + ) { + if (forwards) { + return Duration( + seconds: currentPosition + 2, + ); + } else { + return Duration( + milliseconds: currentPosition - 2, + ); + } + } + + void initPositionListener() { + AudioService.position.listen((event) { + if (position.isClosed) { + return; + } + + position.add(event); + }); + } + + void onDispose() { + position.close(); + audioService.stop(); + } +} diff --git a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart index 47d7f3341..37cd58ba0 100644 --- a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart +++ b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart @@ -292,7 +292,7 @@ class HTMLParser { ), ); }, - ) + ), ], );