Skip to content

Commit

Permalink
feat: English on Mobile (#1303)
Browse files Browse the repository at this point in the history
* feat: english

* feat: a bunch stuff

* fix: rename odin to multiple choice

* fix: challenge types for English

* fix: give intructions a proper background

* feat: controllable inputs

* fix: show words directly before and after BLANK

* feat: check input answers with dynamic input borders

* feat: go to next challenge when completed

* fix: get step numbering correct for English tasks

* fix: simplify titles for English and MCQ view

* feat: audio widget

* feat: add audio element to MCQ and style it

* fix: select grid widget for the rest of the superblocks

* fix: mutliple small issues

* fix: do not init editor files on non editor challenges

* feat: add feedback widget

* fix: give dialogue header some margin

* feat: add Niraj's suggestions

* fix: save initial value

* fix: put button at bottom and use audio service

* fix: title and replace paragraph on the last array index

* fix: stop audio if english challenge is closed

* fix: temp fix to prevent UI bug in MCQ challenges

This is a temporary fix

---------

Co-authored-by: Niraj Nandish <[email protected]>
  • Loading branch information
Sembauke and Nirajn2311 authored Dec 18, 2024
1 parent 634e044 commit 7bbe860
Show file tree
Hide file tree
Showing 14 changed files with 931 additions and 30 deletions.
3 changes: 3 additions & 0 deletions mobile-app/devtools_options.yaml
Original file line number Diff line number Diff line change
@@ -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:
74 changes: 73 additions & 1 deletion mobile-app/lib/models/learn/challenge_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class Challenge {
final List<ChallengeTest> tests;
final List<ChallengeFile> 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;
Expand All @@ -58,6 +62,8 @@ class Challenge {
required this.files,
this.question,
this.assignments,
this.fillInTheBlank,
this.audio,
});

factory Challenge.fromJson(Map<String, dynamic> data) {
Expand All @@ -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<ChallengeTest>((file) => ChallengeTest.fromJson(file))
.toList(),
Expand Down Expand Up @@ -145,7 +157,9 @@ class Question {
return Question(
text: data['text'],
answers: (data['answers'] ?? [])
.map<Answer>((answer) => Answer.fromJson(answer))
.map<Answer>(
(answer) => Answer.fromJson(answer),
)
.toList(),
solution: data['solution'],
);
Expand Down Expand Up @@ -220,3 +234,61 @@ class ChallengeFile {
);
}
}

class FillInTheBlank {
final String sentence;
final List<Blank> blanks;

const FillInTheBlank({required this.sentence, required this.blanks});

factory FillInTheBlank.fromJson(Map<String, dynamic> data) {
return FillInTheBlank(
sentence: data['sentence'],
blanks: data['blanks']
.map<Blank>(
(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<String, dynamic> 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<String, dynamic> data) {
return EnglishAudio(
fileName: data['filename'],
startTime: data['startTime'].toString(),
startTimeStamp: data['startTimestamp'].toString(),
finishTimeStamp: data['finishTimestamp'].toString(),
);
}
}
7 changes: 6 additions & 1 deletion mobile-app/lib/models/learn/curriculum_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ class Block {
});

static bool checkIfStepBased(String superblock) {
return superblock == '2022/responsive-web-design';
List<String> stepbased = [
'2022/responsive-web-design',
'a2-english-for-developers'
];

return stepbased.contains(superblock);
}

factory Block.fromJson(
Expand Down
51 changes: 49 additions & 2 deletions mobile-app/lib/service/audio/audio_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -68,6 +69,7 @@ class AudioPlayerHandler extends BaseAudioHandler {
@override
Future<void> stop() async {
await _audioPlayer.stop();
_audioType = '';
return super.stop();
}

Expand All @@ -82,8 +84,9 @@ class AudioPlayerHandler extends BaseAudioHandler {
return super.onTaskRemoved();
}

// @override
// Future<void> playFromUri() async {}
Duration? duration() {
return _audioPlayer.duration;
}

Future<void> loadEpisode(
Episodes episode,
Expand Down Expand Up @@ -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) {
Expand Down
94 changes: 86 additions & 8 deletions mobile-app/lib/ui/views/learn/block/block_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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)
],
Expand All @@ -112,6 +123,74 @@ class BlockView extends StatelessWidget {
);
}

Widget dialogueWidget(
List<ChallengeOrder> challenges,
BuildContext context,
BlockViewModel model,
) {
List<List<ChallengeOrder>> structure = [];

List<ChallengeOrder> 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,
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand All @@ -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 =
Expand All @@ -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,
Expand Down
15 changes: 12 additions & 3 deletions mobile-app/lib/ui/views/learn/challenge/challenge_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
Loading

0 comments on commit 7bbe860

Please sign in to comment.