From 0cabd457dfb9b948f26c877f4995e943fa9e7cab Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Tue, 1 Jul 2025 16:35:06 +0700 Subject: [PATCH 1/3] feat(model): add solutions --- .../lib/models/learn/challenge_model.dart | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 012c19648..8e7a52573 100644 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ b/mobile-app/lib/models/learn/challenge_model.dart @@ -73,6 +73,8 @@ class Challenge { // Challenge Type 15 - Odin final List? assignments; + final List>? solutions; + Challenge({ required this.id, required this.block, @@ -94,6 +96,7 @@ class Challenge { this.audio, this.scene, required this.hooks, + this.solutions, }); factory Challenge.fromJson(Map data) { @@ -134,6 +137,13 @@ class Challenge { hooks: Hooks.fromJson( data['hooks'] ?? {'beforeAll': ''}, ), + solutions: data['solutions'] != null + ? (data['solutions'] as List) + .map>((solutionList) => (solutionList as List) + .map((file) => SolutionFile.fromJson(file)) + .toList()) + .toList() + : null, ); } @@ -180,6 +190,10 @@ class Challenge { 'solution': question.solution, }) .toList(), + 'solutions': challenge.solutions + ?.map((solutionList) => + solutionList.map((file) => file.toJson()).toList()) + .toList(), }; } } @@ -494,3 +508,61 @@ class EnglishAudio { ); } } + +class SolutionFile { + final String head; + final String tail; + final String id; + final List history; + final String name; + final String ext; + final String path; + final String fileKey; + final String contents; + final String seed; + final String? error; + + SolutionFile({ + required this.head, + required this.tail, + required this.id, + required this.history, + required this.name, + required this.ext, + required this.path, + required this.fileKey, + required this.contents, + required this.seed, + this.error, + }); + + factory SolutionFile.fromJson(Map data) { + return SolutionFile( + head: data['head'] ?? '', + tail: data['tail'] ?? '', + id: data['id'] ?? '', + history: ((data['history'] ?? []) as List).cast(), + name: data['name'] ?? '', + ext: data['ext'] ?? '', + path: data['path'] ?? '', + fileKey: data['fileKey'] ?? '', + contents: data['contents'] ?? '', + seed: data['seed'] ?? '', + error: data['error'], + ); + } + + Map toJson() => { + 'head': head, + 'tail': tail, + 'id': id, + 'history': history, + 'name': name, + 'ext': ext, + 'path': path, + 'fileKey': fileKey, + 'contents': contents, + 'seed': seed, + 'error': error, + }; +} From adcc915d258e5a208bf4081e646857352271f5f1 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:16:26 +0700 Subject: [PATCH 2/3] feat(view, viewmodel): display project demo --- .../lib/models/learn/challenge_model.dart | 21 ++ .../learn/challenge/challenge_viewmodel.dart | 66 ++++++ .../challenge_widgets/project_demo.dart | 77 +++++++ .../description/description_widget_view.dart | 210 +++++++++++++----- 4 files changed, 324 insertions(+), 50 deletions(-) create mode 100644 mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 8e7a52573..547e8f288 100644 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ b/mobile-app/lib/models/learn/challenge_model.dart @@ -45,6 +45,23 @@ enum HelpCategory { const HelpCategory(this.value); } +enum DemoType { + onLoad('onLoad'), + onClick('onClick'); + + final String value; + const DemoType(this.value); + + static DemoType? fromValue(String? value) { + if (value == null) return null; + try { + return DemoType.values.firstWhere((type) => type.value == value); + } catch (_) { + return null; + } + } +} + class Challenge { final String id; final String block; @@ -74,6 +91,7 @@ class Challenge { final List? assignments; final List>? solutions; + final DemoType? demoType; Challenge({ required this.id, @@ -97,6 +115,7 @@ class Challenge { this.scene, required this.hooks, this.solutions, + this.demoType, }); factory Challenge.fromJson(Map data) { @@ -144,6 +163,7 @@ class Challenge { .toList()) .toList() : null, + demoType: DemoType.fromValue(data['demoType']), ); } @@ -194,6 +214,7 @@ class Challenge { ?.map((solutionList) => solutionList.map((file) => file.toJson()).toList()) .toList(), + 'demoType': challenge.demoType?.value, }; } } 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 6ae4ac0da..438c248db 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart @@ -520,6 +520,70 @@ class ChallengeViewModel extends BaseViewModel { return document; } + Future writeDemoDocument( + String doc, List>? solutions) async { + if (solutions == null || solutions.isEmpty) { + return parse(doc).outerHtml; + } + + List solutionFiles = solutions[0]; + List cssFiles = + solutionFiles.where((file) => file.ext == 'css').toList(); + List jsFiles = + solutionFiles.where((file) => file.ext == 'js').toList(); + List htmlFiles = + solutionFiles.where((file) => file.ext == 'html').toList(); + + String text = htmlFiles.isNotEmpty ? htmlFiles[0].contents : doc; + Document document = parse(text); + + if (cssFiles.isNotEmpty) { + // Insert CSS as '); + } + if (document.head != null) { + document.head!.append(parseFragment(cssBuffer.toString())); + } + } + + if (jsFiles.isNotEmpty) { + // Insert JS as '); + } + if (document.body != null) { + document.body!.append(parseFragment(jsBuffer.toString())); + } + } + + String viewPort = ''' + '''; + Document viewPortParsed = parse(viewPort); + Node meta = viewPortParsed.getElementsByTagName('META')[0]; + document.getElementsByTagName('HEAD')[0].append(meta); + + return document.outerHtml; + } + + Future provideDemo(List>? solutions) async { + // Use the first solution's HTML file as the base doc + if (solutions == null || solutions.isEmpty) { + return null; + } + + List htmlFiles = + solutions[0].where((file) => file.ext == 'html').toList(); + String doc = htmlFiles.isNotEmpty ? htmlFiles[0].contents : ''; + String document = await writeDemoDocument(doc, solutions); + + return document; + } + String parseUsersConsoleMessages(String string) { if (!string.startsWith('testMSG')) { return '

$string

'; @@ -731,6 +795,8 @@ class ChallengeViewModel extends BaseViewModel { return DescriptionView( description: challenge.description, instructions: challenge.instructions, + solutions: challenge.solutions, + demoType: challenge.demoType, challengeModel: model, maxChallenges: maxChallenges, title: challenge.title, diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart new file mode 100644 index 000000000..1c43e652b --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; + +class ProjectDemo extends StatelessWidget { + const ProjectDemo({ + super.key, + required this.solutions, + required this.model, + }); + + final List>? solutions; + final ChallengeViewModel model; + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: EdgeInsets.zero, + backgroundColor: Colors.transparent, + child: Container( + width: double.infinity, + height: double.infinity, + color: Theme.of(context).scaffoldBackgroundColor, + child: Column( + children: [ + AppBar( + automaticallyImplyLeading: false, + title: Text('Demo'), + actions: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + Expanded( + child: FutureBuilder( + future: model.provideDemo(solutions), + builder: (context, snapshot) { + if (snapshot.hasData) { + if (snapshot.data is String) { + return InAppWebView( + initialData: InAppWebViewInitialData( + data: snapshot.data as String, + mimeType: 'text/html', + ), + onWebViewCreated: (controller) { + model.setWebviewController = controller; + }, + initialSettings: InAppWebViewSettings( + // TODO: Set this to true only in dev mode + isInspectable: true, + ), + ); + } + } + + if (snapshot.hasError) { + return Center( + child: Text(context.t.error), + ); + } + + return const Center( + child: CircularProgressIndicator(), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart b/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart index a75bf7e2a..15cfc3fc7 100644 --- a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:freecodecamp/extensions/i18n_extension.dart'; +import 'package:freecodecamp/models/learn/challenge_model.dart'; +import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/ui/views/learn/widgets/challenge_widgets/project_demo.dart'; import 'package:freecodecamp/ui/views/learn/widgets/description/description_widget_model.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; import 'package:stacked/stacked.dart'; @@ -10,6 +13,8 @@ class DescriptionView extends StatelessWidget { super.key, required this.description, required this.instructions, + required this.solutions, + required this.demoType, required this.challengeModel, required this.maxChallenges, required this.title, @@ -17,6 +22,8 @@ class DescriptionView extends StatelessWidget { final String description; final String instructions; + final List>? solutions; + final DemoType? demoType; final ChallengeViewModel challengeModel; final int maxChallenges; final String title; @@ -32,65 +39,168 @@ class DescriptionView extends StatelessWidget { builder: (context, model, child) { HTMLParser parser = HTMLParser(context: context); + final shouldShowDemo = (solutions != null && solutions!.isNotEmpty); + + Widget demoOnClickSection() { + return Padding( + padding: const EdgeInsets.all(12.0), + child: RichText( + text: TextSpan( + style: + DefaultTextStyle.of(context).style.copyWith(fontSize: 20), + children: [ + const TextSpan( + text: 'Build an app that is functionally similar to ', + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + backgroundColor: Colors.transparent, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => ProjectDemo( + solutions: solutions, + model: challengeModel, + ), + ); + }, + child: const Text( + 'this example project', + style: TextStyle( + fontSize: 20, + decoration: TextDecoration.underline, + color: FccColors.gray00, + ), + ), + ), + ), + const TextSpan( + text: + '. Try not to copy the example project, give it your own personal style.', + ), + ], + ), + ), + ); + } + + Widget demoOnLoadSection() { + return Padding( + padding: const EdgeInsets.all(12.0), + child: RichText( + text: TextSpan( + style: + DefaultTextStyle.of(context).style.copyWith(fontSize: 20), + children: [ + const TextSpan( + text: "Here's ", + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + backgroundColor: Colors.transparent, + ), + onPressed: () { + showDialog( + context: context, + builder: (context) => ProjectDemo( + solutions: solutions, + model: challengeModel, + ), + ); + }, + child: const Text( + 'a preview', + style: TextStyle( + fontSize: 20, + decoration: TextDecoration.underline, + color: FccColors.gray00, + ), + ), + ), + ), + const TextSpan( + text: ' of what you will build.', + ), + ], + ), + ), + ); + } + return SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: instructions.isNotEmpty || description.isNotEmpty - ? [ - Row( + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only(left: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.instructions, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - if (isMultiStepChallenge) - Text( - context.t.step_count( - splitTitle[1], - maxChallenges.toString(), - ), - style: const TextStyle( - fontSize: 14, - color: Colors.white70, - ), - ) - ], + Text( + context.t.instructions, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, ), ), + if (isMultiStepChallenge) + Text( + context.t.step_count( + splitTitle[1], + maxChallenges.toString(), + ), + style: const TextStyle( + fontSize: 14, + color: Colors.white70, + ), + ) ], ), - Expanded( - child: Builder( - builder: (context) { - final scrollController = ScrollController(); - return Scrollbar( - thumbVisibility: true, - trackVisibility: true, - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: parser.parse( - description, - ) + - parser.parse(instructions), - ), - ), - ); - }, + ), + ], + ), + Expanded( + child: Builder( + builder: (context) { + final scrollController = ScrollController(); + return Scrollbar( + thumbVisibility: true, + trackVisibility: true, + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (demoType == DemoType.onClick && shouldShowDemo) + demoOnClickSection(), + if (demoType == DemoType.onLoad && shouldShowDemo) + demoOnLoadSection(), + ...parser.parse(description), + ...parser.parse(instructions), + ], + ), ), - ), - ] - : [], + ); + }, + ), + ), + ], ), ); }, From 98a87ed2c461364c492124d8ebc9410d1c8e4d07 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Wed, 2 Jul 2025 05:03:18 +0700 Subject: [PATCH 3/3] fix: change writeDemoDocument to synchronous --- .../learn/challenge/challenge_viewmodel.dart | 14 +++--- .../challenge_widgets/project_demo.dart | 43 ++++++++----------- 2 files changed, 23 insertions(+), 34 deletions(-) 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 438c248db..3d49e814e 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart @@ -520,8 +520,7 @@ class ChallengeViewModel extends BaseViewModel { return document; } - Future writeDemoDocument( - String doc, List>? solutions) async { + String writeDemoDocument(String doc, List>? solutions) { if (solutions == null || solutions.isEmpty) { return parse(doc).outerHtml; } @@ -560,9 +559,9 @@ class ChallengeViewModel extends BaseViewModel { } String viewPort = ''' - '''; + initial-scale=1.0, maximum-scale=1.0, + user-scalable=no" name="viewport"> + '''; Document viewPortParsed = parse(viewPort); Node meta = viewPortParsed.getElementsByTagName('META')[0]; document.getElementsByTagName('HEAD')[0].append(meta); @@ -570,8 +569,7 @@ class ChallengeViewModel extends BaseViewModel { return document.outerHtml; } - Future provideDemo(List>? solutions) async { - // Use the first solution's HTML file as the base doc + String? provideDemo(List>? solutions) { if (solutions == null || solutions.isEmpty) { return null; } @@ -579,7 +577,7 @@ class ChallengeViewModel extends BaseViewModel { List htmlFiles = solutions[0].where((file) => file.ext == 'html').toList(); String doc = htmlFiles.isNotEmpty ? htmlFiles[0].contents : ''; - String document = await writeDemoDocument(doc, solutions); + String document = writeDemoDocument(doc, solutions); return document; } diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart index 1c43e652b..398466785 100644 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart +++ b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart @@ -36,35 +36,26 @@ class ProjectDemo extends StatelessWidget { ], ), Expanded( - child: FutureBuilder( - future: model.provideDemo(solutions), - builder: (context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data is String) { - return InAppWebView( - initialData: InAppWebViewInitialData( - data: snapshot.data as String, - mimeType: 'text/html', - ), - onWebViewCreated: (controller) { - model.setWebviewController = controller; - }, - initialSettings: InAppWebViewSettings( - // TODO: Set this to true only in dev mode - isInspectable: true, - ), - ); - } - } - - if (snapshot.hasError) { - return Center( - child: Text(context.t.error), + child: Builder( + builder: (context) { + final html = model.provideDemo(solutions); + if (html != null) { + return InAppWebView( + initialData: InAppWebViewInitialData( + data: html, + mimeType: 'text/html', + ), + onWebViewCreated: (controller) { + model.setWebviewController = controller; + }, + initialSettings: InAppWebViewSettings( + // TODO: Set this to true only in dev mode + isInspectable: true, + ), ); } - return const Center( - child: CircularProgressIndicator(), + child: Text('No demo available'), ); }, ),