Skip to content

Commit adcc915

Browse files
committed
feat(view, viewmodel): display project demo
1 parent 0cabd45 commit adcc915

File tree

4 files changed

+324
-50
lines changed

4 files changed

+324
-50
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,23 @@ enum HelpCategory {
4545
const HelpCategory(this.value);
4646
}
4747

48+
enum DemoType {
49+
onLoad('onLoad'),
50+
onClick('onClick');
51+
52+
final String value;
53+
const DemoType(this.value);
54+
55+
static DemoType? fromValue(String? value) {
56+
if (value == null) return null;
57+
try {
58+
return DemoType.values.firstWhere((type) => type.value == value);
59+
} catch (_) {
60+
return null;
61+
}
62+
}
63+
}
64+
4865
class Challenge {
4966
final String id;
5067
final String block;
@@ -74,6 +91,7 @@ class Challenge {
7491
final List<String>? assignments;
7592

7693
final List<List<SolutionFile>>? solutions;
94+
final DemoType? demoType;
7795

7896
Challenge({
7997
required this.id,
@@ -97,6 +115,7 @@ class Challenge {
97115
this.scene,
98116
required this.hooks,
99117
this.solutions,
118+
this.demoType,
100119
});
101120

102121
factory Challenge.fromJson(Map<String, dynamic> data) {
@@ -144,6 +163,7 @@ class Challenge {
144163
.toList())
145164
.toList()
146165
: null,
166+
demoType: DemoType.fromValue(data['demoType']),
147167
);
148168
}
149169

@@ -194,6 +214,7 @@ class Challenge {
194214
?.map((solutionList) =>
195215
solutionList.map((file) => file.toJson()).toList())
196216
.toList(),
217+
'demoType': challenge.demoType?.value,
197218
};
198219
}
199220
}

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,70 @@ class ChallengeViewModel extends BaseViewModel {
520520
return document;
521521
}
522522

523+
Future<String> writeDemoDocument(
524+
String doc, List<List<SolutionFile>>? solutions) async {
525+
if (solutions == null || solutions.isEmpty) {
526+
return parse(doc).outerHtml;
527+
}
528+
529+
List<SolutionFile> solutionFiles = solutions[0];
530+
List<SolutionFile> cssFiles =
531+
solutionFiles.where((file) => file.ext == 'css').toList();
532+
List<SolutionFile> jsFiles =
533+
solutionFiles.where((file) => file.ext == 'js').toList();
534+
List<SolutionFile> htmlFiles =
535+
solutionFiles.where((file) => file.ext == 'html').toList();
536+
537+
String text = htmlFiles.isNotEmpty ? htmlFiles[0].contents : doc;
538+
Document document = parse(text);
539+
540+
if (cssFiles.isNotEmpty) {
541+
// Insert CSS as <style> tags into <head>
542+
StringBuffer cssBuffer = StringBuffer();
543+
for (var css in cssFiles) {
544+
cssBuffer.writeln('<style>${css.contents}</style>');
545+
}
546+
if (document.head != null) {
547+
document.head!.append(parseFragment(cssBuffer.toString()));
548+
}
549+
}
550+
551+
if (jsFiles.isNotEmpty) {
552+
// Insert JS as <script> tags before </body>
553+
StringBuffer jsBuffer = StringBuffer();
554+
for (var js in jsFiles) {
555+
jsBuffer.writeln('<script>${js.contents}</script>');
556+
}
557+
if (document.body != null) {
558+
document.body!.append(parseFragment(jsBuffer.toString()));
559+
}
560+
}
561+
562+
String viewPort = '''<meta content="width=device-width,
563+
initial-scale=1.0, maximum-scale=1.0,
564+
user-scalable=no" name="viewport">
565+
<meta>''';
566+
Document viewPortParsed = parse(viewPort);
567+
Node meta = viewPortParsed.getElementsByTagName('META')[0];
568+
document.getElementsByTagName('HEAD')[0].append(meta);
569+
570+
return document.outerHtml;
571+
}
572+
573+
Future<String?> provideDemo(List<List<SolutionFile>>? solutions) async {
574+
// Use the first solution's HTML file as the base doc
575+
if (solutions == null || solutions.isEmpty) {
576+
return null;
577+
}
578+
579+
List<SolutionFile> htmlFiles =
580+
solutions[0].where((file) => file.ext == 'html').toList();
581+
String doc = htmlFiles.isNotEmpty ? htmlFiles[0].contents : '';
582+
String document = await writeDemoDocument(doc, solutions);
583+
584+
return document;
585+
}
586+
523587
String parseUsersConsoleMessages(String string) {
524588
if (!string.startsWith('testMSG')) {
525589
return '<p>$string</p>';
@@ -731,6 +795,8 @@ class ChallengeViewModel extends BaseViewModel {
731795
return DescriptionView(
732796
description: challenge.description,
733797
instructions: challenge.instructions,
798+
solutions: challenge.solutions,
799+
demoType: challenge.demoType,
734800
challengeModel: model,
735801
maxChallenges: maxChallenges,
736802
title: challenge.title,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
3+
import 'package:freecodecamp/extensions/i18n_extension.dart';
4+
import 'package:freecodecamp/models/learn/challenge_model.dart';
5+
import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart';
6+
7+
class ProjectDemo extends StatelessWidget {
8+
const ProjectDemo({
9+
super.key,
10+
required this.solutions,
11+
required this.model,
12+
});
13+
14+
final List<List<SolutionFile>>? solutions;
15+
final ChallengeViewModel model;
16+
17+
@override
18+
Widget build(BuildContext context) {
19+
return Dialog(
20+
insetPadding: EdgeInsets.zero,
21+
backgroundColor: Colors.transparent,
22+
child: Container(
23+
width: double.infinity,
24+
height: double.infinity,
25+
color: Theme.of(context).scaffoldBackgroundColor,
26+
child: Column(
27+
children: [
28+
AppBar(
29+
automaticallyImplyLeading: false,
30+
title: Text('Demo'),
31+
actions: [
32+
IconButton(
33+
icon: const Icon(Icons.close),
34+
onPressed: () => Navigator.of(context).pop(),
35+
),
36+
],
37+
),
38+
Expanded(
39+
child: FutureBuilder(
40+
future: model.provideDemo(solutions),
41+
builder: (context, snapshot) {
42+
if (snapshot.hasData) {
43+
if (snapshot.data is String) {
44+
return InAppWebView(
45+
initialData: InAppWebViewInitialData(
46+
data: snapshot.data as String,
47+
mimeType: 'text/html',
48+
),
49+
onWebViewCreated: (controller) {
50+
model.setWebviewController = controller;
51+
},
52+
initialSettings: InAppWebViewSettings(
53+
// TODO: Set this to true only in dev mode
54+
isInspectable: true,
55+
),
56+
);
57+
}
58+
}
59+
60+
if (snapshot.hasError) {
61+
return Center(
62+
child: Text(context.t.error),
63+
);
64+
}
65+
66+
return const Center(
67+
child: CircularProgressIndicator(),
68+
);
69+
},
70+
),
71+
),
72+
],
73+
),
74+
),
75+
);
76+
}
77+
}

0 commit comments

Comments
 (0)