Skip to content

Commit 7bbe860

Browse files
SembaukeNirajn2311
andauthored
feat: English on Mobile (#1303)
* 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]>
1 parent 634e044 commit 7bbe860

File tree

14 files changed

+931
-30
lines changed

14 files changed

+931
-30
lines changed

mobile-app/devtools_options.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
description: This file stores settings for Dart & Flutter DevTools.
2+
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
3+
extensions:

mobile-app/lib/models/learn/challenge_model.dart

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ class Challenge {
3737
final List<ChallengeTest> tests;
3838
final List<ChallengeFile> files;
3939

40+
// English Challenges
41+
final FillInTheBlank? fillInTheBlank;
42+
final EnglishAudio? audio;
43+
4044
// Challenge Type 11 - Video
4145
// TODO: Renamed to questions and its an array of questions
4246
Question? question;
@@ -58,6 +62,8 @@ class Challenge {
5862
required this.files,
5963
this.question,
6064
this.assignments,
65+
this.fillInTheBlank,
66+
this.audio,
6167
});
6268

6369
factory Challenge.fromJson(Map<String, dynamic> data) {
@@ -71,6 +77,12 @@ class Challenge {
7177
superBlock: data['superBlock'],
7278
videoId: data['videoId'],
7379
challengeType: data['challengeType'],
80+
fillInTheBlank: data['fillInTheBlank'] != null
81+
? FillInTheBlank.fromJson(data['fillInTheBlank'])
82+
: null,
83+
audio: data['scene'] != null
84+
? EnglishAudio.fromJson(data['scene']['setup']['audio'])
85+
: null,
7486
tests: (data['tests'] ?? [])
7587
.map<ChallengeTest>((file) => ChallengeTest.fromJson(file))
7688
.toList(),
@@ -145,7 +157,9 @@ class Question {
145157
return Question(
146158
text: data['text'],
147159
answers: (data['answers'] ?? [])
148-
.map<Answer>((answer) => Answer.fromJson(answer))
160+
.map<Answer>(
161+
(answer) => Answer.fromJson(answer),
162+
)
149163
.toList(),
150164
solution: data['solution'],
151165
);
@@ -220,3 +234,61 @@ class ChallengeFile {
220234
);
221235
}
222236
}
237+
238+
class FillInTheBlank {
239+
final String sentence;
240+
final List<Blank> blanks;
241+
242+
const FillInTheBlank({required this.sentence, required this.blanks});
243+
244+
factory FillInTheBlank.fromJson(Map<String, dynamic> data) {
245+
return FillInTheBlank(
246+
sentence: data['sentence'],
247+
blanks: data['blanks']
248+
.map<Blank>(
249+
(blank) => Blank.fromJson(blank),
250+
)
251+
.toList(),
252+
);
253+
}
254+
}
255+
256+
class Blank {
257+
final String answer;
258+
final String feedback;
259+
260+
const Blank({
261+
required this.answer,
262+
required this.feedback,
263+
});
264+
265+
factory Blank.fromJson(Map<String, dynamic> data) {
266+
return Blank(
267+
answer: data['answer'],
268+
feedback: data['feedback'] ?? '',
269+
);
270+
}
271+
}
272+
273+
class EnglishAudio {
274+
final String fileName;
275+
final String startTime;
276+
final String startTimeStamp;
277+
final String finishTimeStamp;
278+
279+
const EnglishAudio({
280+
required this.fileName,
281+
required this.startTime,
282+
required this.startTimeStamp,
283+
required this.finishTimeStamp,
284+
});
285+
286+
factory EnglishAudio.fromJson(Map<String, dynamic> data) {
287+
return EnglishAudio(
288+
fileName: data['filename'],
289+
startTime: data['startTime'].toString(),
290+
startTimeStamp: data['startTimestamp'].toString(),
291+
finishTimeStamp: data['finishTimestamp'].toString(),
292+
);
293+
}
294+
}

mobile-app/lib/models/learn/curriculum_model.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ class Block {
6969
});
7070

7171
static bool checkIfStepBased(String superblock) {
72-
return superblock == '2022/responsive-web-design';
72+
List<String> stepbased = [
73+
'2022/responsive-web-design',
74+
'a2-english-for-developers'
75+
];
76+
77+
return stepbased.contains(superblock);
7378
}
7479

7580
factory Block.fromJson(

mobile-app/lib/service/audio/audio_service.dart

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:io';
33

44
import 'package:audio_service/audio_service.dart';
55
import 'package:freecodecamp/models/code-radio/code_radio_model.dart';
6+
import 'package:freecodecamp/models/learn/challenge_model.dart';
67
import 'package:freecodecamp/models/podcasts/episodes_model.dart';
78
import 'package:freecodecamp/models/podcasts/podcasts_model.dart';
89
import 'package:just_audio/just_audio.dart';
@@ -68,6 +69,7 @@ class AudioPlayerHandler extends BaseAudioHandler {
6869
@override
6970
Future<void> stop() async {
7071
await _audioPlayer.stop();
72+
_audioType = '';
7173
return super.stop();
7274
}
7375

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

85-
// @override
86-
// Future<void> playFromUri() async {}
87+
Duration? duration() {
88+
return _audioPlayer.duration;
89+
}
8790

8891
Future<void> loadEpisode(
8992
Episodes episode,
@@ -171,6 +174,50 @@ class AudioPlayerHandler extends BaseAudioHandler {
171174
}
172175
}
173176

177+
Duration parseTimeStamp(String timeStamp) {
178+
if (timeStamp == '0') {
179+
return const Duration(milliseconds: 0);
180+
}
181+
return Duration(
182+
milliseconds: (double.parse(timeStamp) * 1000).round(),
183+
);
184+
}
185+
186+
// TODO: Move to a common constants like file for curriculum stuff
187+
String returnUrl(String fileName) {
188+
return 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/$fileName';
189+
}
190+
191+
bool canSeek(bool forward, int currentDuration, EnglishAudio audio) {
192+
currentDuration =
193+
currentDuration + parseTimeStamp(audio.startTimeStamp).inSeconds;
194+
195+
if (forward) {
196+
return currentDuration + 2 <
197+
parseTimeStamp(audio.finishTimeStamp).inSeconds;
198+
} else {
199+
return currentDuration - 2 >
200+
parseTimeStamp(audio.startTimeStamp).inSeconds;
201+
}
202+
}
203+
204+
void loadEnglishAudio(EnglishAudio audio) async {
205+
_audioPlayer.setAudioSource(
206+
ClippingAudioSource(
207+
start: parseTimeStamp(audio.startTimeStamp),
208+
end: parseTimeStamp(audio.finishTimeStamp),
209+
child: AudioSource.uri(
210+
Uri.parse(
211+
returnUrl(audio.fileName),
212+
),
213+
),
214+
),
215+
);
216+
await _audioPlayer.load();
217+
setEpisodeId = '';
218+
_audioType = 'english';
219+
}
220+
174221
void _notifyAudioHandlerAboutPlaybackEvents() {
175222
_audioPlayer.playbackEventStream.listen(
176223
(PlaybackEvent event) {

mobile-app/lib/ui/views/learn/block/block_view.dart

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ class BlockView extends StatelessWidget {
4141
bool isCertification = block.challenges.length == 1 &&
4242
block.superBlock.dashedName != 'the-odin-project';
4343

44+
bool isDialogue =
45+
block.superBlock.dashedName == 'a2-english-for-developers';
46+
4447
int calculateProgress =
4548
(model.challengesCompleted / block.challenges.length * 100).round();
4649

@@ -91,7 +94,15 @@ class BlockView extends StatelessWidget {
9194
model: model,
9295
block: block,
9396
),
94-
if (!isCertification && isStepBased) ...[
97+
if (isDialogue) ...[
98+
buildDivider(),
99+
dialogueWidget(
100+
block.challenges,
101+
context,
102+
model,
103+
)
104+
],
105+
if (!isCertification && isStepBased && !isDialogue) ...[
95106
buildDivider(),
96107
gridWidget(context, model)
97108
],
@@ -112,6 +123,74 @@ class BlockView extends StatelessWidget {
112123
);
113124
}
114125

126+
Widget dialogueWidget(
127+
List<ChallengeOrder> challenges,
128+
BuildContext context,
129+
BlockViewModel model,
130+
) {
131+
List<List<ChallengeOrder>> structure = [];
132+
133+
List<ChallengeOrder> dialogueHeaders = [];
134+
int dialogueIndex = 0;
135+
136+
dialogueHeaders.add(challenges[0]);
137+
structure.add([]);
138+
139+
for (int i = 1; i < challenges.length; i++) {
140+
if (challenges[i].title.contains('Dialogue')) {
141+
structure.add([]);
142+
dialogueHeaders.add(challenges[i]);
143+
dialogueIndex++;
144+
} else {
145+
structure[dialogueIndex].add(challenges[i]);
146+
}
147+
}
148+
return Column(
149+
children: [
150+
...List.generate(structure.length, (step) {
151+
return Column(
152+
children: [
153+
Padding(
154+
padding: const EdgeInsets.all(16.0),
155+
child: Text(
156+
dialogueHeaders[step].title,
157+
style: const TextStyle(
158+
fontSize: 18,
159+
fontWeight: FontWeight.bold,
160+
),
161+
),
162+
),
163+
GridView.count(
164+
physics: const ClampingScrollPhysics(),
165+
shrinkWrap: true,
166+
padding: const EdgeInsets.all(16),
167+
crossAxisCount: (MediaQuery.of(context).size.width / 70 -
168+
MediaQuery.of(context).viewPadding.horizontal)
169+
.round(),
170+
children: List.generate(
171+
structure[step].length,
172+
(index) {
173+
return Center(
174+
child: ChallengeTile(
175+
block: block,
176+
model: model,
177+
challengeId: structure[step][index].id,
178+
step: int.parse(
179+
structure[step][index].title.split('Task')[1],
180+
),
181+
isDowloaded: false,
182+
),
183+
);
184+
},
185+
),
186+
),
187+
],
188+
);
189+
})
190+
],
191+
);
192+
}
193+
115194
Widget gridWidget(BuildContext context, BlockViewModel model) {
116195
return SizedBox(
117196
height: 300,
@@ -133,7 +212,8 @@ class BlockView extends StatelessWidget {
133212
child: ChallengeTile(
134213
block: block,
135214
model: model,
136-
step: step,
215+
step: step + 1,
216+
challengeId: block.challengeTiles[step].id,
137217
isDowloaded: (snapshot.data is bool
138218
? snapshot.data as bool
139219
: false),
@@ -271,18 +351,18 @@ class ChallengeTile extends StatelessWidget {
271351
required this.model,
272352
required this.step,
273353
required this.isDowloaded,
354+
required this.challengeId,
274355
}) : super(key: key);
275356

276357
final Block block;
277358
final BlockViewModel model;
278359
final int step;
279360
final bool isDowloaded;
361+
final String challengeId;
280362

281363
@override
282364
Widget build(BuildContext context) {
283-
bool isCompleted = model.completedChallenge(
284-
block.challengeTiles[step].id,
285-
);
365+
bool isCompleted = model.completedChallenge(challengeId);
286366

287367
return GridTile(
288368
child: Container(
@@ -304,8 +384,6 @@ class ChallengeTile extends StatelessWidget {
304384
width: 70,
305385
child: InkWell(
306386
onTap: () async {
307-
String challengeId = block.challengeTiles[step].id;
308-
309387
String url = LearnService.baseUrl;
310388

311389
String fullUrl =
@@ -319,7 +397,7 @@ class ChallengeTile extends StatelessWidget {
319397
},
320398
child: Center(
321399
child: Text(
322-
(step + 1).toString(),
400+
step.toString(),
323401
style: const TextStyle(
324402
fontWeight: FontWeight.bold,
325403
fontSize: 18,

mobile-app/lib/ui/views/learn/challenge/challenge_view.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import 'package:freecodecamp/extensions/i18n_extension.dart';
66
import 'package:freecodecamp/models/learn/challenge_model.dart';
77
import 'package:freecodecamp/models/learn/curriculum_model.dart';
88
import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart';
9-
import 'package:freecodecamp/ui/views/learn/challenge/templates/odin/odin_view.dart';
9+
import 'package:freecodecamp/ui/views/learn/challenge/templates/english/english_view.dart';
10+
import 'package:freecodecamp/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart';
1011
import 'package:freecodecamp/ui/views/learn/challenge/templates/python-project/python_project_view.dart';
1112
import 'package:freecodecamp/ui/views/learn/challenge/templates/python/python_view.dart';
1213
import 'package:freecodecamp/ui/views/learn/widgets/console/console_view.dart';
@@ -60,13 +61,21 @@ class ChallengeView extends StatelessWidget {
6061
challengesCompleted: challengesCompleted,
6162
currentChallengeNum: currChallengeNum,
6263
);
63-
} else if (challenge.challengeType == 15) {
64-
return OdinView(
64+
} else if (challenge.challengeType == 15 ||
65+
challenge.challengeType == 19) {
66+
return MultipleChoiceView(
6567
challenge: challenge,
6668
block: block,
6769
challengesCompleted: challengesCompleted,
6870
currentChallengeNum: currChallengeNum,
6971
);
72+
} else if (challenge.challengeType == 22 ||
73+
challenge.challengeType == 21) {
74+
return EnglishView(
75+
challenge: challenge,
76+
currentChallengeNum: currChallengeNum,
77+
block: block,
78+
);
7079
} else {
7180
ChallengeFile currFile = model.currentFile(challenge);
7281

0 commit comments

Comments
 (0)