diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart index 012c19648..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; @@ -73,6 +90,9 @@ class Challenge { // Challenge Type 15 - Odin final List? assignments; + final List>? solutions; + final DemoType? demoType; + Challenge({ required this.id, required this.block, @@ -94,6 +114,8 @@ class Challenge { this.audio, this.scene, required this.hooks, + this.solutions, + this.demoType, }); factory Challenge.fromJson(Map data) { @@ -134,6 +156,14 @@ 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, + demoType: DemoType.fromValue(data['demoType']), ); } @@ -180,6 +210,11 @@ class Challenge { 'solution': question.solution, }) .toList(), + 'solutions': challenge.solutions + ?.map((solutionList) => + solutionList.map((file) => file.toJson()).toList()) + .toList(), + 'demoType': challenge.demoType?.value, }; } } @@ -494,3 +529,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, + }; +} 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..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,6 +520,68 @@ class ChallengeViewModel extends BaseViewModel { return document; } + String writeDemoDocument(String doc, List>? solutions) { + 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; + } + + String? provideDemo(List>? solutions) { + 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 = writeDemoDocument(doc, solutions); + + return document; + } + String parseUsersConsoleMessages(String string) { if (!string.startsWith('testMSG')) { return '

$string

'; @@ -731,6 +793,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..398466785 --- /dev/null +++ b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_demo.dart @@ -0,0 +1,68 @@ +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: 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: Text('No demo available'), + ); + }, + ), + ), + ], + ), + ), + ); + } +} 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), + ], + ), ), - ), - ] - : [], + ); + }, + ), + ), + ], ), ); },